diff --git a/.aipass/hooks.json b/.aipass/hooks.json index d08161767..1a9e59dc3 100644 --- a/.aipass/hooks.json +++ b/.aipass/hooks.json @@ -93,7 +93,7 @@ "pre_edit_gate": { "enabled": true, "handler": "aipass.hooks.apps.handlers.security.edit_gate.handle", - "matcher": "Edit|MultiEdit|Write|NotebookEdit" + "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit" }, "git_gate": { "enabled": true, diff --git a/.gitignore b/.gitignore index 197d8b543..e47a3a277 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ build/ # AIPass runtime state (local to each installation) AIPASS_REGISTRY.json +AIPASS_ROOTS.json .trinity/ .watchdog/ .ai_mail.local/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b8fd70849..01f3c3cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,576 @@ PyPI version — not the changelog header. --- +## [2026-09-01] — one test universe: the night CI-red stopped being a norm (FPLAN-0460/0461) · v2.8.1 + +### Fixed + +- **Round 13 is a skip list, not a round** (FPLAN-0461, the ruling applied as + written). The round-12 board's real cures all held — the coverage report + step survived, the direction table went green on every POSIX leg — and every + remaining red was round-11/12 test instrumentation meeting a real host: six + tests in two files. Per the one-fix ruling they were skipped by name with + the reason in the mark, not cured with another instrument: seedgo's three + instrument self-checks (red on a different interpreter each board, green + only on the 3.12 box that wrote them; the worlds they self-check remain + exercised by every test that uses them) skip unconditionally, owner to + rewrite as measurement after the PR; backup's three coverage pins skip on + Windows only, because they are the live POSIX regression net for the real + report-step landmine and that stays armed. One commit for the whole board. + +- **Round 12: the hybrid row, the wrong attribute, and the coverage landmine — + under the one-fix-at-a-time ruling** (FPLAN-0461 continued). Patrick's ruling + landed mid-round and governs everything after: fix one thing at a time, no new + test instrumentation on this PR — future reds get the minimal cure or a named + skip; never add a test without a defect it pins (the audit-tests lane is the + instrument for test bloat, not more tests). This train is the last pre-ruling + pair, both cures of round-11-authored test code, zero production changes. + Seedgo: the 3.11 hybrid row measured instead of predicted (accessor removed, + flavour still an object — the route is a call-time module read), and the + stand-down guard read `PurePath._flavour` where 3.10/3.11 define it on the + CONCRETE class — a guard reading the wrong attribute is indistinguishable + from no guard and looks more careful in review; now reads the concrete class + with three measured arms. The Windows no-op break cured by breaking the + routing identity TOWARD the dialect the host is not — and their sweep then + caught the cure itself asserting this box's dialect as a literal, so what + ships is all measurement: the child announces where it started, a two-row + direction table decides where it must go, the both-sides pin asserts + movement rather than a destination, and hosts where the reading cannot move + report instead of convicting. Per the ruling, two extra pins were taken + back out; three surviving mutants are named on the record instead of + pinned — all one species, an arm that only fires on a host this box is + not. Backup: the coverage + report landmine reproduced to the line — a test compiled code under a fake + filename inside the source tree, so coverage recorded a measured file absent + from disk and the REPORT step failed with zero test failures; the two sites + called likely-inert were NOT (drive-lettered PureWindowsPath literals are + RELATIVE on POSIX, resolved against cwd at trace time — they fire from the + branch cwd). Cure: one guarded mint point every future site inherits by + construction, fabrications moved under tmp_path, both cwds pinned. Rule + banked: whether a fabricated filename mints depends on the cwd, because + abspath does. Also this round: the test-quality audit lane ran for the first + time in the campaign (harness 12/13; its one fail correctly named real + unattributed tree writes) and joins the standing verification bar alongside + the standards audit; @aipass dispatched on round-2 research — a ranked + per-test inventory for a 20k-test fleet, culling precedent, and AI test + bloat as a named industry problem. + +- **Round 11: the fixture's first catch, the captured accessor, and the end of + arms** (FPLAN-0461 continued). Round 10's board (c5b6e173): six red legs and + every red ONE FILE — while spawn's import guard went green on every leg for + the first time in six boards and seedgo's alias cures held everywhere. Three + tests. The first was seedgo's day-old left-nothing-behind fixture CONVICTING + A REAL FLEET DEFECT on its first live catch — and the defect was devpulse's + own: the watchdog storage resolver walked up from Path.cwd() looking for + AIPASS_REGISTRY.json (machine-local, absent on every fresh checkout) and + fell back to Path.cwd()/.watchdog, so a parallel devpulse test planted + .watchdog/ at the repo root on CI — invisible on every dev machine because + the marker exists there, and the timer copy carried a comment defending the + fallback as production-only, refuted by CI within a day. Cured by devpulse: + root derived from __file__, both walks and both fallbacks deleted, red-first + pins reproducing the exact fallback answer before the fix, agent tests + hermetic via module-local autouse. The other two were seedgo's own round-10 + pins carrying the species one level up — the assumption in the assertion of + the very pin that documents the assumption. ROUTES_THROUGH pinned "Path's + routing module IS os.path" — the 3.12 spelling; the fact that DECIDES the + route is the pre-3.11 CAPTURED ACCESSOR (pathlib holds a private copy of + realpath a module patch can never reach), measured by a spy and pinned as a + four-row literal table falsifiable from 3.12 twice over. DISARMS_world_a's + negative control asserted a 3.11+ fact on every interpreter — and devpulse's + machine-shaped diagnosis was REFUTED WITH RECEIPTS (child run from a + marker-less cwd: still survives), while devpulse's OWN overclaim (red on + all six including 3.12) was corrected on seedgo's challenge: the 3.12 leg + carried only the watchdog conviction, and the correction relocated the one + unexplained leg to 3.13 — where a targeted addendum found THE THIRD + MECHANISM: 3.13's resolve reads os.path at call time, so the chimera does + not sever the route, it MOVES it — world A's own patch rides along on + ntpath and the defect dies. The cure is the end of arms: after three rounds + of predict-from-version-and-platform tables each refuted by a real host, + the control now ASKS THE CHILD which routes survive the chimera — right on + 3.13, nt, pre-3.11, and interpreters nobody has shipped yet, without naming + any of them. The sweep pre-killed three more 3.13 reds and caught seedgo's + own hour-old pin carrying the species it was written to cure. Fleet lines + banked: an arm that only fires on a host you do not have needs a table, + not a branch (the mutant that deletes it is invisible on the only machine + you can run); a new world needs its stand-down and its host-already-has-one + pin on day one. Devpulse 607x2, seedgo 2916x2 across the two passes, audits + 100. + +- **Round 10: the assumption moves into the assertion next** (FPLAN-0461 + continued). Round 9's board (84175b82): production clean a FOURTH consecutive + board, red owners down to two, five unique failing tests — and spawn's + windows-leg skip-by-name cure HELD on the real Windows runner. Every red was + one species, seen twice in one round: round 9 made the verdicts honest + three-state, and the reds were the ASSERTIONS over those verdicts, still + spelling the answer of the host they were written on. Devpulse diagnosed all + four mechanisms from the CI evidence before dispatching; two survived contact, + two were corrected with receipts and the conclusions stood. Spawn: the posix + control now measures a control (untouched-host run first, forcing posix must + LEAVE the row, forcing nt must MOVE it — no literal anywhere, and the row + runs again against an object-flavour host so the restored-literal mutant dies + on the exact CI red reproduced locally); the parse_parts dialect mismatch is + gated by a MEASUREMENT, not a version table — a probe asks the host's own + pathlib what it reaches for, rows carrying a module-shaped flavour skip by + name with the host's own answer, and the probe carries a negative control + that grows 3.10's consumer to prove it can fire (a silent child reads as + unknown and CLOSES the rows). Seedgo: the alias judgement keys on measured + module identity with both arms asserted and an nt-identity world reachable + from Linux forever — the world's two halves pinned alone, because identity + without the cwd-reading realpath is the Linux answer wearing an nt label; + and the refuted mechanism's cure was one dialect-neutral literal — the probe + had built its input as abspath(os.sep), which is rooted-driveless on posix + but DRIVE-rooted on nt: the input changed shape underneath the row. Seedgo's + property sweep then caught THREE literals true only where ntpath carries the + 3.12 legacy clause — next board's 3.13 reds, killed tonight — and grew a + fourth verdict for a foreign ntpath that reads the cwd anyway (an emulation + reported as a platform). The night's found-object: backslash-named files + minting at the repo root, spawn measured them without naming an author, + devpulse attributed them to seedgo's window, and seedgo corrected the + attribution with receipts — their throwaway sweep instrument, not shipped + code — while writing down the harness property that made it a commit risk at + all: the composed runner hands every child cwd = repo root, so any relative + write anywhere lands in the tree. Pinned one level stronger than asked: a + fixture that fails the module if children leave ANYTHING behind, deliberately + non-deleting, acquitting removed entries, its watched directory pinned to the + real child cwd. Fleet lessons banked: a mutant that deletes a COUPLING is + invisible to a single-mutant run (the pair is the mutant); an assertion that + holds cannot be killed by deleting it (mutate the source); and the naming + checker learned PYTEST_CONTRACT_GLOBALS from devpulse's refused auto-fix — + 2074 files, 7 verdicts change, all FAIL to no-verdict. Spawn 977x2 with 12/13 + mutants named, seedgo 2901x2 with 8/8 named, both audits 100. + +- **Round 9: the instruments learned to say where they cannot speak — cross + terms, one-dimension litmuses, and the wrong question** (FPLAN-0461 + continued). Round 8's board (9bd2618b) was five red legs, zero production + defects for the third consecutive board — and every red was a cross term: + a shape simulated on a real host where neither side is 3.12. Three owners, + one evening. **Spawn** (965×2, 147 pins in the file, 14/15 mutants each named + with the assertion that fired) decomposed the cross into four mechanisms and + found the deepest one under it: the old symptom table pinned whether a + rebinding reaches the route *afterwards* — an answer that moves with the host + for reasons unrelated to the emulation. Three reds, one wrong question. The + cures are the terminal shapes: symptoms as judgements over measured values, + simulations that declare which real hosts they can speak from and skip with + the child's own reason elsewhere, and the platform asymmetry stated plainly — + the nt getcwd row is measurable from posix (it rests on the cwd read sitting + above the isabs check), the posix row is not measurable from nt (it rests on + what counts as absolute), so on the windows leg those rows now skip by name. + Two findings that travel: *an emulation friendlier than the thing it emulates + hides the defect* (their flavour stand-in forwarded to posixpath, which has a + realpath the real `_PosixFlavour` lacks — delegate to the thing you stand in + FOR, not a convenient neighbour), and rebinding `pathlib.Path` moves pathlib's + own `cls is Path` identity test — the tidier rewrite is the broken one. + **Seedgo** (2882×2, 8/8 mutants named) found round 8's defect committed again + one file over — the *reproduction partner* of the cure installed a three-method + accessor stand-in that decapitated a real 3.10: "a cure and its reproduction + are two instruments, and I applied the rule to one of them." Their synthetic + accessor now delegates every unnamed attribute ("a stand-in for a namespace + that answers three questions is a trap for the fourth") and prints whether it + synthesised or stood down. The alias-catchability red confirmed the dispatch + hypothesis against CPython source: 3.13 removed ntpath's LEGACY BUG clause + (3.12 ntpath.py:99–102) keeping rooted-driveless paths absolute — a platform + fact that was a version fact, their own checker-pack sentence landing in their + own file; the verdict is now three-state, because a verdict that cannot tell + two different losses apart hides the more interesting one. **Flow** (1041×2 + + bare-checkout, 15/15 valid mutants dead, 2 invalid published as invalid) found + worse than their reported red: *a one-dimension litmus is blind on the host + that already is that dimension* — on Windows their runner fake installed + Windows-shaped values over Windows-shaped values and the litmus measured + nothing while reporting green. The cure is a runner **set**: both platform + fakes run every direction and the verdict must not move, so on any host at + least one row is a real measurement. Trigger's round-9 line — a mutant report + says *which assertion fired*, not only how many — was taken by all three + owners the same evening it was proposed. Devpulse fixed the unowned repo-root + `conftest.py` docstring and refused the auto-fix rename of + `collect_ignore_glob` (the lowercase name is pytest's API contract; the tidier + spelling silently disables the mechanism), reported to seedgo as a live + checker counterexample. The seedgo-audit leg and test 3.12 went green this + board; expected next: the campaign's first fully green board, with the + windows-leg posix-only rows skipping by name rather than asserting what the + runner can contradict. + +- **Round 8: the assumption moves up a level every time it is cured — round 7's + instruments were sound, and the machinery that installs them was not** + (FPLAN-0461 continued). Round 7's board (commit 68ab5132) came back 20/20 + checks with six non-green and **zero production defects** — the first board + where Windows ran the fleet's own litmus for real: an nt host, and every + expectation still keyed on "here = posix host" moved. Six owners, every red an + instrument, every cure red-first on Linux. **Spawn** (930×2, 14/16 mutants + 2 + named equivalents) owned the largest share and the round's sentence: *the + version-shaped assumption moves up a level every time it is cured* — round 6 + the world, round 7 the emulation, round 8 the installer. Their shape preludes + reached `PurePath._parse_path` (3.12+; older interpreters spell it + `_parse_args`), so the child died before printing and all 37 failures on + 3.10/3.11 wore the arming probe's message — *a harness crash wears the message + of the instrument it never reached*. Their 3.13 red was the probe building its + own input **inside** the world it measures, meeting 3.13's ntpath dropping the + legacy clause that kept rooted-driveless paths absolute; the cure vocabulary — + halves with three verdicts (NATIVE / INSTALLED / UNAVAILABLE-with-the-child's- + own-reason), decisions by measurement not `hasattr`, the probe literal built + once and its VALUE reused — is the round's export. They also corrected the + dispatcher's brief by measurement: the coverage leg runs **3.13, not 3.12** + (ci.yml:101), so the "dies under the coverage tracer" hypothesis is refuted — + one mechanism had been counted twice. **Flow** (1029×2 + bare-checkout, 13/13) + extended the verdict: *an instrument's inputs are behaviour too* — round 7 + cured the captured function and left the probe path built from `os.sep` and + `pathlib.__file__`, the runner's spelling, which posixpath rightly reads as + relative; and they named the litmus's missing dimension — it varied the + emulated host, but what convicted CI was the platform the **process** runs on, + so WINDOWS_RUNNER now fakes exactly what a probe can read to build a path and + is deliberately *not* a world. Their confession pair: a structural check that + reddened on its own documentation, and an nt literal mangled by its enclosing + triple-quote (`\not` became a newline — the instrument's input corrupted by + the language it is written in). **Trigger** (1092×2, 0 skips) refuted the + dispatcher's mechanism while confirming the conclusion — the probe path was + `sys.executable`, drive-lettered on the runner, and the fix a one-line + dialect-neutral literal; their addendum deleted a control clause a mutant + proved could never fire ("it looked more careful than what replaced it") and + found their own round-7 two-sentinel discriminator gone dark one round later + by the mechanism it guarded against. Their round-9 line rides ahead: *a mutant + report should say which assertion fired, not only how many — false kills are + worse than false survivors, because nobody re-examines a kill.* **Seedgo** + (2869×2, 8/8) named the verdict's mirror — *an instrument must not REMOVE + behaviour it is not testing* — after their 3.10 emulation stubbed the accessor + a real 3.10 routes its whole API through, decapitating the interpreter before + the measured defect was reached; and their windows red was their own round-7 + sentence ("absolute never reads the cwd is a posixpath fact") shipped as a + constant one file over — the nt alias row is now *reported* as unfalsifiable + there rather than asserted away. Their stacking find (host == emulated is one + layer) was acted on by spawn the same hour: applied each emulation twice, + pinned no-recursion, and killed the mutant that proves the property is + measured, with seedgo's name on the pin. **Skills** cured **six** docstring + files, not the three CI flagged nor the five seedgo's mail named — the sixth + was invisible to both lists because its verdict never *moved*, and seedgo's + list was correctly a diff of moved verdicts: *a handed list is a lower bound + whenever the thing that produced it is a diff — re-measure until the number + stops moving.* They also caught spawn's `ntpath.py:678` citation reading `:673` + on 3.12.3 — the ordering travels, the line number doesn't. **Ai_mail** (1446×2) + reproduced their discarded string-after-import before fixing it (`__doc__` was + None on the live module), worked the tree not the list, and named an unarmed + species for seedgo to confirm: an f-string opening a module body reads as a + docstring to a human and is refused by `ast.get_docstring`. The seedgo-audit + leg itself was the dispatcher's own train collision — `--all` swept the new + ast-based checker onto CI before the flagged owners had fixed their files. + Composed verify before this commit: full universe from the repo root under + exact CI mode, plus per-branch both-rootdirs by every owner; the train + deliberately carries seedgo's held work (the posix-literal checker pack, the + docstring rule, the settle-recheck, and the round-7 litmus pins). + +- **Round 7: an instrument must not import behaviour it is not testing — the + round-6 instruments met the interpreters and platforms they were built to + emulate, and lost everywhere except home** (FPLAN-0461 continued). Round 6's + board was 12 green with test 3.12 passing — the exact interpreter the fleet + builds on — and red on 3.10/3.11/3.13 and windows-setup (13 failed / + 19,697 passed on *Python 3.12.10*, isolating pure nt semantics). Every red + was round 6's own new accessor instruments; zero production defects, all + prior cures held. Memory measured the mechanism in 553 seconds and named the + fleet verdict: the probes were built out of the **live** `os.path` and + `os.getcwd`, so their verdicts carried platform behaviour they weren't + testing — on nt `os.path` *is* `ntpath`, whose `realpath` reads the cwd + unconditionally (round 4's own headline turned back on its finders) and + makes a POSIX-absolute literal drive-relative (`/tmp` → `D:\tmp`). Three + rules, each paid for: emulate *both* platforms or neither; build emulations + from `posixpath`/`ntpath` **by name**, never `os.path` (which *is* the + host — flow's first try recursed into itself 997 frames deep); and probes + asking "did my patch reach X" capture a **sentinel** that returns its + argument, so the original's platform behaviour can't answer for it. Plus the + litmus that finds all three: run every probe under the *opposite* platform's + emulation and require the verdict not to move — it caught spawn's next CI + red before it shipped. Spawn's cluster decomposed differently by reading + CPython per version: 3.10/3.11 reds were their emulation *crashing while + parsing* (a wrapped `_flavour` lost `parse_parts`), 3.13's patch was a write + nothing reads, Windows instantiates `WindowsPath` outside the patched + hierarchy — the new emulation replaces exactly one method and proves it + armed (`ROUTE_ARMED`/`ROUTE_DARK`) before anything downstream claims. + Refinements traveled mid-round: skills corrected flow's alias trap by source + read (off-Windows `ntpath.realpath` is a *wrapper*, not an alias — an `is` + test proves nothing) and kept `getcwd` a live capture deliberately (a + sentinel can never arm, so sentinelling it takes the eagerness pin dark); + trigger closed that hole with two distinguishable sentinels (eager answers + CAPTURED, lazy answers MOVED — no platform behaviour in the discriminator); + drone generalized: a pin that reads a *value* back can be measuring the + host, so the litmus lives beside every return-value pin as a test. On the + record: trigger's mis-aimed mutant that would have logged a false survivor + ("looks exactly like a real hole"), seedgo's refuted round-6 mechanism + rewritten in place with attribution rather than deleted, drone's stale + held-tree list corrected from git rather than memory, and skills' recurrence + of the poisoned-baseline species caught by ai_mail's guard — the guard is + the cure, not the `finally`. Seven branches: memory 1596×2, seedgo 2808×2, + spawn 902×2 (14/14 mutants), flow 1022×2 + bare-checkout, drone 1349×2, + skills 355×2 + 1119 lib, trigger 1087×2 with 0 skips. + +- **Round 6: the interpreter is part of the platform — the 3.10 leg convicted + the instruments, and the fleet cured the capture** (FPLAN-0461 continued). + Round 5's CI left two named clusters and both are dead. The Python 3.10 reds + were all *arming probes refusing honestly*: on ≤3.10, `pathlib` reads + `os.path.realpath` (and `os.getcwd` — skills) through a `_NormalAccessor` + that **captured its copy when pathlib was first imported**, so rebinding the + module attribute rebinds a name nothing reads again and the injected worlds + never armed. My first diagnosis ("3.10 doesn't delegate") was wrong — memory + read the CPython source and refuted it, seedgo reproduced the capture by + construction, and the correction propagated mid-flight; the cure is four + hasattr-guarded lines (patch the captured accessor too) and the same world + arms on every interpreter — no version tables, no skipif. Eight branches + cured, each making the 3.10 row *falsifiable locally* by rebuilding the + capture on 3.12 rather than asserting from the CI red. The round's traps, + each measured: patch as `staticmethod` or a plain function eats the path + into `self` and raise-shaped pins stay green for the wrong reason + (return-value pins cure); exercise through an instance; probe with absolute + paths (a relative path convicts for the path's *shape* — skills had this + live); emulations must capture eagerly (flow shipped the trap inside the + instrument built to avoid it and their ninth mutant caught it); `cwd` and + `resolve` ride *different* captured attributes (skills' M16). Structural + finds beyond the cure: spawn's gate was reporting one red while several + sibling pins *silently skipped* under an unrelated platform message (gates + now fail, not skip, where no platform reason exists); trigger's version + escape hatch cited the retracted diagnosis and was green only by + import-order luck on the one leg where order decides — deleted, and made + falsifiable on a host that cannot produce the condition it excused; + memory's windows-setup pins (the last two on the board) got the six-row + os.name table with both nt rows measured twice. Seedgo's sentence carries + the round: *a careful falsifiable table on a wrong mechanism is more durable + than a guess — read the source before keying an instrument to it.* + +- **Round 5: the round-4 pins met the real Windows platform, and every red + taught a structural rule** (FPLAN-0461 continued). CI on the round-4 commit + came back red where the new pins *measured the platform for the first time* — + POSIX facts asserted as universal. Eight branches cured by their owners: + per-platform expectation tables replace universal assertions, each stating + which rows are measured live here vs derived from the CI red (a CI red is a + *negative* measurement — it proves not-unconditionally-X, never which value; + recording a guess is inventing a measurement, so unfalsifiable rows are + pinned as `None` *decisions*). The round's converged rules, each named to its + finder: **emulate the platform, not just the denial** (prax — getcwd-denied + Linux is a genuinely different world from Windows-with-no-cwd, because + `ntpath.abspath` never touches getcwd); **split cause / outcome / link into + separate pins** (canary — a future red names its own mechanism, and the link + pin makes derived rows falsifiable locally); **key the table on the variable + that actually decides** (ai_mail — `os.name` answers which path module, + `sys.platform` answers does-the-filesystem-fold: darwin folds on posixpath); + **a table row no local platform can falsify enters silently** (ai_mail's + darwin mutant — the original defect's shape, one platform along); **separate + the judgement from the world** (commons — their first rebuild shipped the + unreachable-branch species one level up in the instrument: four survivors all + mutated branches Linux never executes; judgements became plain functions fed + synthetic values, every platform case reachable on any host); **a line-scoped + waiver whose number moves fails open and silent** (trigger — one round-4 + edit shifted four bypass.json waivers, resurfacing as "new" violations; + re-derive, never hand-adjust); **mutate the source, never the test** (daemon + — deleting a currently-true assertion proves nothing). Real defects found + under the noise: prax's log-dir resolver mkdir'd the literal `` (21 + such dirs in /tmp; reserved-character crash on Windows — cured, bracketed + names → "unknown"); backup's fence compared one normalised operand against + one raw (refused its *own kin* on Windows — cured both sides); trigger's + `_systemctl` had never been executed by its suite (extraction exposed it; + `return True` left all 40 tests green — 9 pins now). Exactly-once log pins + on Linux cured at conftest altitude (flow/memory — the bare-checkout world + logs a fallback diagnostic *inside* the test window on CI only). Daemon + caught their own CI-red-in-waiting pre-commit (a pin asserting the + gitignored `tools/` exists — verified in the absent world). Composed + universe green before commit; per-branch by owners, both rootdirs, audits + 100 across every touched branch. + +- **Round 4: the import-time dead-cwd defect cured in all 16 branches (~150 + sites), and the instrument that watches the cure** (FPLAN-0461 continued). + `ntpath.realpath` reads `os.getcwd()` unconditionally and `Path.resolve()` + routes through it, so every module-level resolve *reached at import* is an + import-time crash on Windows — and every branch's `handlers/__init__.py` + guard called `inspect.stack()` first, masking its whole tree (the mask ran + two layers deep in daemon/api/backup via a json_handler root constant, three + in skills with two *parallel* second layers — counts only become true as + cures land; re-measure until the number stops moving). Each branch cured by + its owner, red-first in two subprocess worlds, mutants run not assumed: + guard walks on `sys._getframe` over `co_filename`, module-level resolves + routed through one guarded `module_file()` helper per branch (diagnostics + inside their own protection — daemon's rule, stderr deduped last resort), + the hot-path `_get_caller_module_name` stack walks cured to + `sys._getframe(2)` with pins asserting the audit trail still *answers* + (aipass's shared fix reaches five shims including spawn's citizen template — + newborns born cured AND born pinned). The deleted second stack walk proved + unreachable from import-shaped pins in nine independent reproductions + (restoring the defect left 1118/1120 green in aipass), so every tree carries + an AST ban — and spawn's capstone correction landed the behavioural sibling: + the branch IS reachable by calling the guard directly from a `-c` child, + adopted fleet-wide within 90 minutes of the relay. Five harness species now + named in the record: a mutant with no pytest output is invalid, never + survived (canary); the lookup frame must be the compiled one (aipass); a + world can be too hostile to convict — denying getcwd *hides* the stack + defect inside `getabsfile`'s own except (prax); a world spelled too + realistically is silently inert — script-run probes make every frame a real + file (commons); a negative control that can fail for the ban's own reason is + a second ban, not a control (spawn, confirmed retroactively by prax). + Report-only for Patrick's ruling, all measured: the handlers fence is + decorative in fact (backup/skills proved a foreign import ALLOWED, causally + — door closed = blocked), and one-word branch kinship tests admit any + same-named directory as kin (api: drone's own `api/` subdir admitted; skills: + 14 same-named dirs on this machine). Rode along: daemon's `_calc_next_run` + read the wall clock instead of the timestamp it was handed — `next_run` had + advertised impossible fires on the human status surface daily (13 pins, 3 + live records repaired); skills' `get_search_paths` raised on dead cwd taking + every list/info/run down — cured by asymmetry (discovery skips, creators + refuse); the medic warning-storm species (a WARNING logged at module import + escalates on volume alone) moved to first-use, deduped. + +- **The two-test-universes defect class, closed at root cause across 7 branches** + (FPLAN-0461, night shift on Patrick's brief "every change we make it turns + red — is it a norm?"; answer: no). The commit gate runs each branch's suite + with rootdir pinned to the branch by its own `pytest.ini`, so the repo-root + `conftest.py` never loads there — while CI composes every conftest in one + process under xdist. All 30 CI reds classified with zero speculation, each + fixed by its owner, every fix independently verified by devpulse: + - repo-root conftest guard (devpulse): consult the handler's own + `_current_json_dir()` seam instead of re-deriving from constants — the + substring scan read seam-adopted modules as unpatched and skipped writes + their tests asserted on (12 reds). + - prax json_handler 1.4.0: the import-time anchor was seeded WITH + `AIPASS_TEST_LOG_DIR`, violating the env-independence its own adopters + pinned as load-bearing; an env-derived anchor is sufficient for the defect + alone, no reload needed — and prax's own suite was green only by + import-order luck (2 reds). Subprocess import pins now bite in both + universes. + - drone registry tests: a stand-in installation that passes the credential + gate on merit, not unverifiability; the memory-gateway import moved inside + the never-take-routing-down guard (5 CI-only reds, one a real bare-world + import crash). + - memory: marker7 guarded on `live_all_tiers` — existence is not sufficiency, + a registry with no rows or no external tier is a half-present world that + skips with a named reason (2 reds); plus registry_scope's module-level + `Path.cwd()` fallback replaced with a `__file__`-derived source root + (the import-time crash behind drone's). + - **the CI polluter, fingerprinted and killed**: `tests/e2e` `routing_root` + wrote a 3-branch synthetic `AIPASS_REGISTRY.json` at the repo root for a + whole module — true sequentially, poison under xdist (40/60 parallel reads + saw the synthetic in @aipass's repro), and on a live machine it overwrote + the real fleet registry with the backup held only in process memory + (kill -9 repro: the 22 rows existing nowhere). The fixture now builds a + throwaway root under tmp — no repo write exists, no teardown left to fail. + Only macOS/Windows CI run e2e, which is why only those jobs saw `{'core'}`. + - aipass `find_registry`: absence is `None`, never `Path.cwd()/…` — the old + code answered a question about one directory with a file from wherever the + caller stood; paired with spawn's `load_registry` minting a fresh + `metadata.id` for missing paths, two lines conjured a registry with a new + trust credential out of nothing (loaded gun, unfired, disarmed). Spawn moved + the mint to `resolve_project_credential`, called by name from the two + create sites — the create path knows it is creating; the load path knows + nothing — and hardened all fifteen resolver call sites to refuse in their + own vocabulary (four were live `None.exists()` crashes, one an + AttributeError escaping a narrow except inside the delete-protection + check). Their live-fleet baseline test now guards on what the WORLD + contains, not where the process runs (`GITHUB_ACTIONS` was protecting one + CI provider and nobody else). + - spawn: the read-torn race harness reports weather as weather (warmup until + both sides prove live, skip-with-counted-reasons backstop, tear-check-first + pinned so a skip can never mask a real tear; proved under load 5.29 with + races exercised) — it was the one intermittent red in prax's 1380-file + fleet batch. + +- **Round two, caught by the first round's own pins on CI** (same night): + - memory: nine private repo-root walks with `Path.cwd()` fallbacks became ONE + implementation (`handlers/repo_root.py`) behind the existing names, with a + parse-tree pin — no lane may define a walk that is not a delegation, no + `return Path.cwd()` anywhere in apps/ — that named all ten offenders + red-first and carries a positive control (a mutant that blinded the filter + left the suite green; skip-reads-green, caught twice in one night). One + reversed test told the whole story: `test_find_repo_root_falls_back_to_cwd` + had pinned the defect as the contract — a green test standing guard over + the bug. + - prax `config/load.py`: the same cwd fallback under `get_system_logger()` — + called at module level by nearly every handler in the fleet — fixed within + the hour of memory's cross-branch report (memory verified 11/11 bare-world + imports clean, then removed their own xfail hatch the moment the blocker + died). + - **hooks bash_writes 1.2.0 — the Windows fence was open**: `shlex`'s POSIX + lexer eats backslashes as escapes, so a drive-absolute foreign path arrived + as one relative token and resolved LOCAL — every scripted-lane category + allowed with exit 0 on Windows CI. Fixed by dual-dialect lexing with + unioned write targets (reading `\` as a separator can only ADD components, + so local can never become foreign by it — the reverse is exactly what + happened); unwalkable foreign roots published as NOT_CAUGHT entry 8, never + a silent allow. Reproduced and pinned on Linux — the bug needed a + backslash, not a Windows box. + - seedgo: three "deliberate violations" ruled — all three were checkers + overclaiming, all three taught instead of waived (pure-declaration modules + `not_applicable` for json_structure; relaying a captured subprocess stream + is an authorship class, and crossed streams are now caught under the same + rule; unused_function says "no caller in this branch" and warns about + cross-branch/dynamic callers it cannot see — the dead-code checker had + nominated the fleet's most load-bearing function, memory's + `changed_entries`, called by hooks' write gate via importlib). Adopted + rule: a structurally detectable exemption goes in the checker; a waiver is + only for what cannot be measured. + - drone: the no-cwd sweep's own new test asserted a fact about the machine + (registry presence) instead of the function — rebuilt against stand-ins, + claim strengthened to "carries a project marker" (the weak claim was + satisfied by the function's own last-resort return); two POSIX separator + pins converted to `Path.parts`. + +- **Windows joins the one universe: the case-fold registry defect, closed at + every site in the fleet** (FPLAN-0461 round 3, dawn shift). ef029782's + windows-setup leg — the last red gate, 20/21 green — failed only in the + night's own new tests, and the diagnosis found a real cross-platform defect + under the noise: pathlib's glob is case-insensitive on Windows (and default + macOS), so `*_REGISTRY.json` also matches `*_registry.json` — and the bait is + everywhere (ten `flow_json/*_registry.json` plan counters, spawn's dotfile + `.template_registry.json` in every branch; pathlib's `*` matches dotfiles). + CI's proof: `find_registry()` served `drone_command_registry.json` as a + trust-anchor candidate. **44 exposed walk sites cured across 9 branches** + (drone 13 → one `registries_in` reader; aipass 7 incl. the shared + `registry_discovery` everyone imports; ai_mail 6 — two more than any list + named, one hidden behind a CONSTANT pattern a literal grep cannot see, and + the live harm measured rather than assumed: a walk that ENDS at the decoy + resolved an external caller to None, and `find_project_root` handed the + fence a wrong root; seedgo 9 → one reader + the measured two-clause checker + discriminator, build queued; memory 4 — detector PERSISTED the folded match + into fleet state forever and a nearer decoy `break`-ended the walk above the + real registry; spawn 2; prax 1; commons 1 — whose + `name != "AIPASS_REGISTRY.json"` filter was the instructive near-miss; and + devpulse 2). Fix shape everywhere: glob, then re-check the name with + case-sensitive `endswith("_REGISTRY.json")` — suffix only, never the stem, + so externally-named registries survive. Memory's follow-through went one + door further: a cased LITERAL folds too, so `find_repo_root`'s + `(parent / "AIPASS_REGISTRY.json").exists()` could accept a lowercase file + as THE repo root — `exists_exactly` now guards it and three sibling lanes. + All pins red-first on Linux by emulating the widened listing, with positive + AND negative instrument controls (aipass's first control re-implemented the + matcher's logic and proved nothing while the walk visited zero files; + commons measured that without the control, breaking the emulator turns every + pin green). Precedent noted for the record: prax's branch_detector and eight + aipass sites already carried the exact cure — it never traveled to the sites + that globbed. + +- **The dead-cwd test world, made honest on Windows** (same round). The night's + deleted-cwd pins died at SETUP on Windows — `WinError 32`, the OS locks a + process's cwd, the world is unbuildable by chdir+rmdir. Two rulings, both + pinned, each owner choosing per claim: memory's — pin the CONDITION + (`os.getcwd()` raises), not the CAUSE (a deleted directory): inject a raising + `getcwd` in the child, licensed by `TestBothConstructionsAgree` (both worlds + must agree on POSIX or the stand-in expires); drone's — Windows removes the + RECIPE, not the STATE (a disconnected share is a live dead cwd), so the + deletion tests keep running where they can under a registered + `deletable_cwd` marker whose skip is itself pinned observable from Linux, + with portable siblings supplying the state and the one genuinely lost claim + named out loud. Spawn measured which ruling their defect needed instead of + picking by taste, and added the round's best control: a negative control for + the positive control, after a mutant showed a lying `CONTROL_LIVE` probe + turning every portable pin vacuously green. Prax's entry found the deeper + vacancy: their dead-cwd pins passed on dev machines because the marker walk + succeeds there and the cwd fallback never runs — the probes now hide the + marker so every machine runs CI's world. Hooks' two remaining Windows reds + were separator spellings in the tests (`Path.parts` tail comparison now) — + and the fence-fix measurement arrived: TestScriptedLaneCatches is GREEN on + the real Windows runner; the dual-dialect gate holds on every OS. + +### Added + +- **daemon+ai_mail+hooks+memory: Vera woke** (FPLAN-0460 closed, c9d0327e — + entry owed from that commit). The first external citizen fired by the real + clock; scheduled manager wakes are headless dispatches under Patrick's + rulings (always bypass permissions, managers-are-Fable-only-managers, marked + sessions, blocked-is-not-ran three-state runstate); hooks edit_gate 1.7.0 + verifies the admin seat through ai_mail's 5-leg grant and bash_writes.py + closes the scripted lane for everyone else; memory's entry_limits 1.6.0 + refuses what a write AUTHORS, never what it CARRIES. + ## [2026-08-30] — the audit-tests lane: testing the tests (DPLAN-0320 campaign) · v2.8.0 ### Added diff --git a/conftest.py b/conftest.py index accb534c6..c0d3df37e 100644 --- a/conftest.py +++ b/conftest.py @@ -31,16 +31,53 @@ def _points_into_repo(mod: types.ModuleType) -> bool: - """True if mod's *JSON_DIR* constant currently resolves inside this repo.""" + """True if mod's next log write would land inside this repo. + + Seam-adopted handlers (prax's 2026-08-30 contract) expose + ``_current_json_dir()`` — the same resolver their own write consults, per + call, honouring both a monkeypatched ``JSON_DIR`` and the + ``AIPASS_TEST_LOG_DIR`` redirect. When it exists, ask IT: re-deriving the + answer here from constants is a second implementation, and it already + misfired once — the seam's ``_IMPORT_TIME_JSON_DIR`` fixed point is + env-independent BY CONTRACT (always the real dir), so a substring scan + read every seam-adopted module as unpatched and skipped writes their + tests were asserting on (CI-only: this conftest loads only on repo-root + runs, which is why 12 log_operation tests were green per-branch and red + in CI on 2026-08-31). + + Handlers without the seam keep the constant scan, with one narrowing: + underscore-private names are anchors, not the live dir, and are ignored. + A public *JSON_DIR* patched outside the repo means the test controls the + write; all-inside means production state, skip. + """ + resolver = getattr(mod, "_current_json_dir", None) + if callable(resolver): + try: + target = Path(str(resolver())).resolve() + except Exception as exc: # a broken resolver must fail safe: skip the write + # Lazy prax import: every wrapped module already imported prax + # itself, and importing it at conftest top would start the logger + # for every collection this guard exists to keep quiet. + from aipass.prax import logger + + logger.warning( + "conftest guard: %s._current_json_dir raised %r — failing safe, write skipped", + mod.__name__, + exc, + ) + return True + return target.is_relative_to(_REPO_ROOT) + verdict = False for name, val in vars(mod).items(): + if name.startswith("_"): + continue if "JSON_DIR" not in name or not isinstance(val, (str, Path)): continue - try: - Path(str(val)).resolve().relative_to(_REPO_ROOT) - return True - except ValueError: + if Path(str(val)).resolve().is_relative_to(_REPO_ROOT): + verdict = True + else: return False - return False + return verdict def _guarded(mod: types.ModuleType, real): diff --git a/pyproject.toml b/pyproject.toml index 81c06d25c..50f609214 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "aipass" -version = "2.8.0" +version = "2.8.1" description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context" readme = "README.md" license = "MIT" diff --git a/src/aipass/__init__.py b/src/aipass/__init__.py index c1c0ee571..cd47e29a7 100644 --- a/src/aipass/__init__.py +++ b/src/aipass/__init__.py @@ -3,4 +3,4 @@ git clone + ./setup.sh — https://github.com/AIOSAI/AIPass """ -__version__ = "2.8.0" +__version__ = "2.8.1" diff --git a/src/aipass/ai_mail/README.md b/src/aipass/ai_mail/README.md index 947301343..b05412886 100644 --- a/src/aipass/ai_mail/README.md +++ b/src/aipass/ai_mail/README.md @@ -5,11 +5,11 @@ **Purpose:** Inter-agent messaging for AIPass. File-based email system that lets agents send, receive, and process messages using `@branch` addresses. No SMTP, no external services — just JSON files and symbolic routing. **Module:** `aipass.ai_mail` **Created:** 2025-11-08 -**Last Updated:** 2026-08-27 +**Last Updated:** 2026-08-31 --- -**Status:** Operational | **Seedgo:** 100% | **Tests:** 1350 pass across 46 files (1332 + 4 live-hygiene skips on a fresh checkout — 2 in `test_live_mailbox_hygiene.py`, 2 in `test_live_contacts_hygiene.py`) | **Battle Tested:** S62 +**Status:** Operational | **Seedgo:** 100% | **Tests:** 1446 pass across 49 files, 0 skipped, both rootdirs (on a fresh checkout 4 live-hygiene tests skip instead — 2 in `test_live_mailbox_hygiene.py`, 2 in `test_live_contacts_hygiene.py`) | **Battle Tested:** S62 ## Quick Start @@ -558,6 +558,63 @@ status, ok = wake_branch("@devpulse", custom_message=prompt, sender="@daemon", s - **Reading the outcome** — `status.find_step("scheduled")` is present only for the headless lane; the interactive spawn names its tmux session in the `spawn` step. +### Unattended Wakes: Permissions, Model, and Marking + +Three rulings from Patrick on 2026-08-30, all after @vera's first external daemon +wake. The fire itself worked; the **lane** failed three ways around it. + +**1 — Always bypass permissions.** *"always bypass permissions always, claude alone +will nvr work."* @vera launched as a bare `claude`, sat in default permission mode, +and had Bash **denied** mid-playbook with nobody present to approve it — she +improvised around it with WebFetch, which is not a thing an unattended agent should +ever have to do. The headless lane had carried `--permission-mode bypassPermissions` +for months; the interactive manager lane had not. It does now, unconditionally: +every route into that spawn comes from `@daemon`, so *attachable* was never the same +thing as *attended*. + +**2 — Fable is managers-only.** *"managers are fable thats it, only manager run +fable."* `resolve_wake_model(citizen_class, requested)` is the one site that decides: + +| Target | Requested model | Runs on | +|---|---|---| +| `citizen_class: manager` | anything, or nothing | **`fable`** — overridden, and the override is logged | +| anyone else | `fable` / `claude-fable-5` / `FABLE` | `DEFAULT_MODEL`, with a warning — the wake still happens | +| anyone else | anything else, or nothing | unchanged: the `wake.model` field, else `DEFAULT_MODEL` | + +- **One read, one source.** `citizen_class` comes from the passport the manager gate + already opens. An unreadable passport arrives as `""` — not a manager — the same + direction `is_manager()` refuses to fail in; inventing a manager would silently + move an ordinary branch onto Fable. +- **Substring, not equality.** The CLI takes both `fable` and `claude-fable-5`, so a + check comparing to the bare alias would let the full id walk straight past the + non-manager half of the rule. +- **The lane names its model.** @vera reached Fable by *CLI default* — the right + answer with no decision behind it. Both spawn lanes now state the model they mean. + +**3 — Daemon sessions are marked.** Patrick killed @vera's live session mid-run: it +was not in the dispatch register (the manager-interactive lane bypasses it) and +`daemon-vera-192848` read as his own leftover tmux. Two markings: + +- **The session name**, `AIPASS-DAEMON-WAKE--` — guaranteed, because + tmux either creates the session under that name or `new-session` already failed. + Loud on purpose: it is read by a person deciding whether to kill a window. +- **A tmux user option**, `@aipass_daemon_wake`, carrying branch/sender/start time — + the queryable half (`tmux show-options -v -t @aipass_daemon_wake`), so a + tool need not string-match a prefix. Set **before** the agent starts, because a + window is killable from the moment it exists. Best-effort: a failure is a `warn` + step on the `mark` label and the wake proceeds — refusing to start real work over + a cosmetic label is the worse trade, but the thin marking is said out loud. + +**Not the dispatch register, and the reason is the register's own contract.** +`open_dispatch()` takes `expected_seconds` from the lane's real timeout, and the +interactive lane has no monitor and no timeout. Every entry it wrote would stay +outstanding forever and go overdue against a number invented here — turning crash +detection into a wall of false alarms. Marking a window and tracking a promised +dispatch are two questions; only one of them has a monitor to close it. The +**headless** lane registers and is closed by `dispatch_monitor`, which is why +routing scheduled manager wakes through it (`scheduled=True`) answers marking and +supervision together. + ### Admin Lane (`admin=True`) Patrick's ruling (DPLAN-0288): @devpulse — and only @devpulse — holds an admin @@ -880,6 +937,109 @@ External projects (outside the AIPass repo) can send to AIPass branches. On deli The contacts system (`contacts.py`) maintains an address book at `.ai_mail.local/contacts.json`, auto-registering branches on every send/receive. This enables fast sender detection for known branches without CWD walking or registry lookups. +### Waking a citizen outside this repo — the external tier + +`wake.resolve_branch()` checks four sources, in strict precedence. **Local always wins:** the first three all resolve inside AIPass home, and only the fourth leaves it. + +| # | Source | Gated by | +|---|---|---| +| 1 | `AIPASS_REGISTRY.json` — core branches | — | +| 2 | The caller's project registry, via `AIPASS_CALLER_CWD` | — | +| 3 | The `projects/*` sweep — the cross-project bridge | verified admin only | +| 4 | The declared-roots **external tier** | — (declaration is the credential) | + +Step 4 consumes @memory's public gateway, `aipass.memory.apps.modules.fleet.external_branches()`, which reads the machine anchor `AIPASS_ROOTS.json` at AIPass home. Nothing here re-reads that file — one anchor, one reader. No anchor means no external roots, which is the ordinary state of a fresh clone, and resolution is then byte-identical to what it was before the tier existed. + +There is no admin gate on step 4: @daemon fires scheduled wakes unverified, and the anchor is a machine-managed file Patrick blessed, so an external root is already an authorised destination. The admin sweep keeps its position *above* the tier — moving it below would let a sibling repo shadow a citizen living in our own `projects/`. + +**Collisions break by declaration order, and are logged anyway.** When two declared roots claim one address, the first-*declared* root wins — the fleet ruling's own tie-break — and an error line names every losing claimant. This was a known gap for one day: `declared_roots()` returned `sorted(found)`, so the winner was alphabetical-by-resolved-path and the tie-break the ruling names could not reach this door. Re-reading the anchor here to recover it would have been a second reader of the file the gateway exists to own, so the collision was made loud and the disagreement raised with @memory instead — who dropped the sort (`registry_scope` 4.1.0, 2026-08-30). The error line stays: a tie-break being correct does not make a collision expected. + +## The Import Guard Needs No Filesystem + +`apps/handlers/__init__.py` runs a branch-access check at import time. It used to +open with `inspect.stack()`, which builds a FrameInfo per frame and reaches +`getsourcefile() -> getmodule() -> os.path.realpath()`. On Windows +`ntpath.realpath` calls `os.getcwd()` unconditionally in its opening lines — +before checking whether the path is even absolute — at a call site inside +`getmodule` that is **not** wrapped in a try. So importing any handler in this +package needed a readable cwd on Windows, and a disconnected share killed the +import of a package whose only job at that moment was to compare a name. +(@spawn's find, 2026-08-31; 16 branches carried it.) + +It walks frames with `sys._getframe` now — `co_filename` is already a string in +memory — and uses `linecache` for the import line. Every `Path.resolve()` is +guarded with a raw-spelling fallback. + +**Why it hid on Linux, and what the pins deny.** `posixpath.realpath` does not +call `getcwd` for an absolute path, so the POSIX equivalent raises earlier inside +`getabsfile()` where `inspect` catches it. Denying `os.getcwd` on Linux proves +nothing here — measured both ways. `test_handlers_guard_import.py` denies +`os.path.realpath`, the call the defect actually makes, and was red against the +pre-fix guard on this machine. + +**A second `inspect.stack()` was deleted outright.** It looked for +``/`` and then returned either way — a second copy of the cwd +dependency in service of a branch that could not change the answer. A discarded +result does not stop being a crash site for being discarded. + +**Known, pre-existing, not fixed here.** `apps/__init__.py` does +`from . import handlers`, so importing `aipass.ai_mail.apps.handlers` imports the +parent package first, which imports handlers itself — the guard therefore sees an +ai_mail file as the caller and allows, and the module is cached before any +external importer is ever seen. Verified identical before and after this change, +so it is not a regression from it. Reported rather than swept: closing it is a +security-behaviour change that deserves its own round. + +## Registry Globs Are Re-Checked in Python + +`pathlib` delegates glob matching to the filesystem, so on Windows and default +macOS `*_REGISTRY.json` **also matches** `*_registry.json`. This repo is full of +bait — 237 lowercase files on this machine when the sweep ran: +`drone_command_registry.json` sits directly beside drone's tree, every branch +carries `.spawn/.template_registry.json` (pathlib `*` matches dotfiles, unlike the +`glob` module), and @flow keeps ten `flow_json/*_registry.json` plan counters. +Found on `ef029782`'s windows-setup leg, root-caused by @drone, swept fleet-wide. + +**Every registry walk in this branch goes through `paths.registries_in()`.** The +glob still does the walking — only the filesystem knows where files are — but it +is not trusted with the *answer*: the name is compared again in Python with +`str.endswith(REGISTRY_SUFFIX)`, where case means what it says. Refusals are +logged, so on Windows there is a record that the filesystem returned something the +pattern never asked for. + +**Suffix, never the stem.** External projects name registries after themselves — +`Vera-Studio_REGISTRY.json`, `vera_studio_REGISTRY.json`, `feel_good_app_REGISTRY.json`. +A filter keyed on the stem would delete real citizens in order to fix this bug, so +all three spellings are pinned as must-survive. + +| Site | What it decides | Reached via | +|---|---|---| +| `paths.find_project_root` | which project this is, for the delivery fence | walk up | +| `users/branch_detection._find_caller_registry` | which registry names the caller | walk up | +| `email/reply._validate_reply_path` | may a reply leave toward this inbox | ancestors | +| `registry/read.resident_registry_paths` | the resident roster | `projects/*/` | +| `registry/read.get_project_tree_branches` | the verified-admin bridge roster | `projects/*/` | +| `registry/read.get_caller_project_branches` | the caller's citizens | walk up | + +The last three were **not** on the sweep's list of four — found by sweeping the +tree rather than working the list, and all three decide *which citizens exist*. + +**What the defect actually did here, measured rather than assumed.** The brief +said mail would land as the wrong citizen. That needs a decoy carrying a +`branches` key, and **zero of the 237 lowercase files on this machine have one** — +so the identity swap is reachable but not currently armed. What *was* live: the +walk-up sites return the **first** registry they meet and stop, so a counter file +ends the walk and a genuine external caller resolves to nothing; and +`find_project_root` returned `src/aipass/drone` as a "project root", which changes +the cross-project fence's answer with no `branches` key needed at all. Both +reproduced against the real tree before the fix and dead after it. + +**The ban is structural.** `test_registry_case_sweep.py` AST-walks `apps/` and +fails on any `.glob()`/`.rglob()` reaching for a registry pattern outside the one +reader — catching a **named constant** as well as a literal, because the +literal-only version reported my own `resident_registry_paths` site clean while it +still held the defect. + ## Architecture Follows the standard AIPass 3-layer pattern: diff --git a/src/aipass/ai_mail/apps/handlers/__init__.py b/src/aipass/ai_mail/apps/handlers/__init__.py index 5a5297ba9..6813a2f72 100755 --- a/src/aipass/ai_mail/apps/handlers/__init__.py +++ b/src/aipass/ai_mail/apps/handlers/__init__.py @@ -1,6 +1,7 @@ """AI_Mail handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "ai_mail" @@ -15,28 +16,76 @@ def _find_real_caller(): - Python's importlib internals - Frozen modules + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on the + Windows CI gate 2026-08-31 (@spawn's find, 16 branches carried it): + inspect.stack() builds a FrameInfo per frame, which reaches + getsourcefile() -> getmodule() -> os.path.realpath(), and on Windows + ntpath.realpath calls os.getcwd() unconditionally in its opening lines — + before it checks whether the path is even absolute — at a call site inside + getmodule that is NOT wrapped in a try. So a branch-access guard that runs at + import time needed a readable cwd on Windows, and a disconnected share killed + every import of this package. + + Invisible on Linux for as long as it existed, and the reason is worth keeping: + posixpath.realpath does not call getcwd for an absolute path, so the POSIX + equivalent raises earlier inside getabsfile() where inspect catches it. + Denying os.getcwd on Linux therefore proves NOTHING here — the pin denies + os.path.realpath, which is the call the defect actually makes. + + A frame's co_filename is already a string in memory; reading it touches no + filesystem at all. + Returns tuple: (file_path, import_line) or (None, None) """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) - - for frame_info in stack: - filename = frame_info.filename - - # Skip this file - if this_file in str(Path(filename).resolve()): + # Path.resolve() reaches the same realpath, so this is guarded too. __file__ + # is already absolute here; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back continue - # Skip Python internals - if filename.startswith("<") or "importlib" in filename: + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename + + # Skip this file. Both spellings, because a failed resolve above leaves + # `resolved` as the raw co_filename and the absolute this_file would + # then never match it. + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - # Found a real file - try to get the import line + # linecache is what inspect used for code_context; called directly it + # reads one named file and returns "" rather than raising when it cannot. + # + # The except below is therefore UNREACHABLE on CPython today — measured, + # not assumed: denying os.stat, tokenize.open, os.path.join and + # io.open_code each produced "" and no exception. Kept anyway, because + # that swallow is a property of one implementation and this runs at + # import time in a guard whose entire job is to not be the reason an + # import fails. The assumption is pinned rather than trusted — see + # test_handlers_guard_import.TestLinecacheSwallowsItsOwnErrors. import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -70,14 +119,14 @@ def _guard_branch_access(): print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) if caller_file is None: - # Can't determine caller from real files - # Check if we're being run from command line (external) - # by looking at the raw stack for or - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return # Allow command-line Python through - return # Allow if truly can't determine + # No caller outside this file: an interactive session, a -c script, or an + # importlib-only stack. All three are allowed, and always were. + # + # This used to walk inspect.stack() AGAIN looking for /, + # then return either way — a second copy of the cwd dependency above, in + # service of a branch that could not change the answer. A discarded + # result does not stop being a crash site for being discarded. + return # Check if caller is from our branch if f"/{MY_BRANCH}/" in caller_file.replace("\\", "/"): diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py index 6aeef0111..115a29dea 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: wake.py # Description: Manual Branch Wake Handler -# Version: 2.4.0 +# Version: 3.0.0 # Created: 2026-03-02 -# Modified: 2026-08-12 +# Modified: 2026-08-30 # ============================================= """ @@ -66,10 +66,22 @@ def _find_claude_bin() -> str: DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done." # Model aliases — passed directly to claude CLI which resolves latest-in-class. -KNOWN_MODEL_ALIASES: frozenset = frozenset({"sonnet", "opus", "haiku"}) +KNOWN_MODEL_ALIASES: frozenset = frozenset({"sonnet", "opus", "haiku", "fable"}) # If this default is flipped again, update the README to match (Patrick, 2026-08-01). DEFAULT_MODEL = "opus" +# The passport value that means "never woken by an ordinary caller". +MANAGER_CLASS = "manager" +# Patrick, 2026-08-30: "managers are fable thats it, only manager run fable". +MANAGER_MODEL = "fable" + +# The one marking tmux cannot half-apply. A session either exists under this +# name or new-session failed, so `tmux ls` never shows a daemon wake wearing a +# hand-made name — which is how @vera's first external wake got killed as a +# human's leftover on 2026-08-30. Loud on purpose: it is read by a person who +# is deciding whether to kill a window, not by a parser. +DAEMON_SESSION_PREFIX = "AIPASS-DAEMON-WAKE-" + # Branches that cannot be woken manually by cross-branch drone commands. # Dispatch-send path (dispatch.py._orchestrate_dispatch_send) bypasses this check. WAKE_BLOCKLIST: frozenset[str] = frozenset({"@devpulse"}) @@ -80,6 +92,63 @@ def is_wake_blocked(target: str) -> bool: return f"@{target.lstrip('@').lower()}" in WAKE_BLOCKLIST +def _is_fable(model: str) -> bool: + """True for any spelling of Fable — bare alias, full id, any casing. + + Substring rather than equality on purpose: the CLI takes both `fable` and + `claude-fable-5`, so a policy that only knew the alias would let the full + id walk straight past the non-manager half of the rule. + """ + return "fable" in model.lower() + + +def resolve_wake_model(citizen_class: str, requested: Optional[str]) -> str: + """The model this wake spawns on, per Patrick's ruling of 2026-08-30. + + Two halves, and the second is the one with teeth. A manager ALWAYS gets + Fable — the requested model is overridden, not merged, because a schedule + naming a model is a preference and the ruling is a policy. Everyone else + NEVER gets Fable: their request is honoured as it is today except for that + one value, which falls back to DEFAULT_MODEL rather than refusing the wake. + + Both overrides are logged. A model silently swapped under a caller is the + kind of change nobody can find later, and the log line is the only place a + schedule's author learns their wake.model field was not what ran. + + `citizen_class` comes from the passport wake_branch already opens for the + manager gate — one read, one source. An unreadable passport arrives here as + "", i.e. not a manager, which is the same direction is_manager() fails in: + an invented manager would silently move a branch onto Fable. + + Args: + citizen_class: identity.citizen_class from the target's passport, or "" + requested: the caller's model (schedule.json wake.model, --model, None) + + Returns: + The model string to hand the CLI. Never None — the wake lane names its + model rather than inheriting whatever the CLI would have defaulted to, + which is exactly how @vera landed on Fable by accident on 2026-08-30. + """ + if citizen_class == MANAGER_CLASS: + if requested and not _is_fable(requested): + logger.info( + "[wake] manager policy: requested model %r overridden to %s (Patrick 2026-08-30)", + requested, + MANAGER_MODEL, + ) + return MANAGER_MODEL + + if requested and _is_fable(requested): + logger.warning( + "[wake] non-manager policy: %r refused — Fable is managers-only, falling back to %s", + requested, + DEFAULT_MODEL, + ) + return DEFAULT_MODEL + + return requested or DEFAULT_MODEL + + # ─── Status Step Tracking ─────────────────────────────── @@ -499,11 +568,47 @@ def is_manager(branch_email: str) -> bool: return passport.get("identity", {}).get("citizen_class", "") == "manager" +def _external_citizens(repo_root: Path) -> List[dict]: + """Every declared-root citizen, straight from @memory's public gateway. + + A named seam rather than an inline import, for two reasons. It is the ONE + place the external tier enters my tree, so a change to @memory's contract + surfaces here as an AttributeError instead of as a wrong answer three steps + downstream; and a test can make the tier fail on demand without reaching + into another branch's module namespace. + + Imported lazily and by MODULE, per @memory's own instruction: the branch + stays importable on an installation where @memory is absent, and refusals + logged inside the gateway stay attributable to @memory. + + Never a second implementation. Reading AIPASS_ROOTS.json here would be the + exact failure the gateway was built to end — two readers of one anchor, + agreeing until the day they do not. + """ + from aipass.memory.apps.modules import fleet + + return fleet.external_branches(repo_root=repo_root) + + def resolve_branch(branch_email: str, admin: bool = False) -> Optional[Tuple[Path, str]]: """Resolve a branch email to its absolute filesystem path. - Checks the AIPass registry first, then falls back to the caller's project - registry via AIPASS_CALLER_CWD for cross-project dispatch. + Four sources, in strict precedence: the AIPass registry, the caller's + project registry via AIPASS_CALLER_CWD, the verified-admin projects/* sweep, + and finally the declared-roots external tier. + + LOCAL ALWAYS WINS is the fleet ruling, and it is why the external step is + LAST rather than merely late. Steps 1-3 all resolve inside AIPass home; + step 4 leaves it. Putting the admin sweep after the external tier would let + a sibling repo's @baud shadow the one living in our own projects/ — so the + sweep keeps its position, unchanged and still admin-only. The external step + carries no admin gate at all: @daemon fires unverified, and the anchor is a + machine-managed file Patrick blessed, so declaration IS the credential. + + Externals are discoverable through @memory's fleet gateway long before they + are wakeable through here — that gap is what failed @vera's first supervised + fire on 2026-08-30 with "Branch not found: @vera" while the same citizen sat + in @daemon's queue. Discovery and firing must read the same definition. Args: branch_email: Target address, with or without the leading @. @@ -558,27 +663,133 @@ def resolve_branch(branch_email: str, admin: bool = False) -> Optional[Tuple[Pat except Exception as e: logger.warning("[wake] resolve_branch projects sweep failed: %s", e) + # Step 4: The declared-roots external tier (FPLAN-0460 phase 5). + # Last, so every local source has already missed. Contained like the two + # steps above it: a tier that cannot answer returns a miss, never a + # traceback — every caller of this function reads None as "not found", and + # wake_branch turns that into an honest failed step. + try: + candidates = [ + citizen + for citizen in _external_citizens(_REPO_ROOT) + if isinstance(citizen.get("email"), str) and citizen["email"].lower() == email + ] + if candidates: + if len(candidates) > 1: + # The ruling's own tie-break, and it now genuinely reaches this + # door. This used to say the opposite: declared_roots() returned + # sorted(found), so the winner was alphabetical-by-resolved-path + # and the tie-break the fleet ruling names was not available + # here. Rather than re-read the anchor to recover it — a second + # reader of the file the gateway exists to own — the collision + # was made loud and the disagreement raised with @memory, who + # dropped the sort (registry_scope 4.1.0, 2026-08-30). The + # gateway iterates roots in declaration order and dedups by + # path, so candidates[0] is the first-declared claimant. + # + # Still logged at error. A tie-break being correct does not make + # a collision expected: two roots claiming one address is a + # thing someone should know about, and a resolution nobody + # logged is indistinguishable from a citizen that only lives in + # one place. + logger.error( + "[wake] %s is claimed by %d declared roots — resolving to %s by DECLARATION ORDER " + "(first-declared root wins, per the fleet ruling). Other claimants: %s", + email, + len(candidates), + candidates[0]["path"], + ", ".join(str(citizen["path"]) for citizen in candidates[1:]), + ) + branch_path = Path(candidates[0]["path"]) + # No exists() check: the gateway admits an external citizen only + # when .trinity/passport.json is a file there, so the directory is + # already proven. A second check here could never be false, and an + # assertion that cannot fail hides which layer is load-bearing. + logger.info("[wake] %s resolved via the declared-roots external tier", email) + return branch_path, email + except Exception as e: + logger.warning("[wake] resolve_branch external tier failed: %s", e) + return None # ─── Interactive Manager Spawn ────────────────────────── +def _mark_daemon_session(session: str, email: str, status: "DispatchStatus") -> None: + """Tag a live tmux session as daemon work, machine-readably. + + Patrick killed @vera's first external wake on 2026-08-30 because nothing in + the session said a machine had started it — he read `daemon-vera-192848` as + his own leftover tmux and took it out mid-playbook. The session NAME is the + guaranteed half of the answer (see DAEMON_SESSION_PREFIX): tmux either + creates the session under that name or the spawn already failed, so a human + reading `tmux ls` cannot get a half-marked session. + + This is the queryable half — a user option any tool can read back with + `tmux show-options -v -t @aipass_daemon_wake` instead of + string-matching a prefix. It is BEST EFFORT and never fatal: the session is + already alive by the time it runs, and refusing to start the agent because + a cosmetic option did not apply would trade real work for a label. + + Owns the "mark" step outright, both outcomes. The first cut recorded the + warn here and an unconditional ok at the end of the spawn — and find_step + returns the LAST record under a label, so every failed marking still read + as marked. A step reported in two places is a step reported by neither. + + Deliberately NOT the dispatch register. open_dispatch() demands an + expected_seconds taken from the lane's own timeout, and this lane has no + monitor and no timeout — every entry it wrote would stay outstanding + forever and go overdue against a number invented here, turning the crash + detector into a wall of false alarms. The register answers "was this + dispatch delivered"; this answers "may a human kill this window". Two + questions, and only one of them has a monitor to close it. + """ + marker = json.dumps({"branch": email, "sender": "@daemon", "started": time.strftime("%Y-%m-%dT%H:%M:%S")}) + try: + subprocess.run( + ["tmux", "set-option", "-t", session, "@aipass_daemon_wake", marker], + check=True, + capture_output=True, + text=True, + timeout=15, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc: + detail = getattr(exc, "stderr", "") or str(exc) + status.warn("mark", f"Session option not set — the name is the only marking: {detail[:120]}") + logger.warning("[wake] %s could not tag session %s as daemon work: %s", email, session, detail) + return + + status.ok("mark", f"'{session}' marked as daemon work — do not kill (@aipass_daemon_wake is set)") + + def _spawn_manager_interactive( branch_path: Path, email: str, prompt: str, - model: Optional[str], + model: str, status: "DispatchStatus", ) -> Tuple["DispatchStatus", bool]: """Spawn an interactive tmux session for a manager branch. - The attended path: a session the user can attach to (same pattern as the + The attachable path: a session the user CAN attach to (same pattern as the manual tmux interactive wake). Used only for @daemon self-wakes that passed the manager gate WITHOUT scheduled=True — the scheduled lane goes headless instead (DPLAN-0287). No dispatch lock or monitor here: the occupancy check is the one-instance guard for interactive sessions, which also means no context pin, no bounce email and no lock cleanup. + + Attachable is not attended. Every route into this function comes from + @daemon, i.e. from a clock rather than a person, so the session launches + with bypassPermissions unconditionally — Patrick's ruling of 2026-08-30 + after @vera sat on a denied Bash prompt here with nobody present to answer + it ("always bypass permissions always, claude alone will nvr work"). The + headless lane has carried the flag for months; this one is the lane that + was still spawning a bare `claude`. + + `model` arrives already resolved by resolve_wake_model() — the policy lives + at one site in wake_branch, and this lane naming its own would be a second + place for the managers-only-Fable rule to drift. """ if shutil.which("tmux") is None: status.fail("tmux", "tmux not found — cannot spawn interactive manager session") @@ -591,9 +802,12 @@ def _spawn_manager_interactive( prompt_file = daemon_dir / "last_wake_prompt.txt" prompt_file.write_text(prompt, encoding="utf-8") - session = f"daemon-{branch_path.name}-{time.strftime('%H%M%S')}" - model_arg = f" --model {shlex.quote(model)}" if model else "" - claude_line = f'{shlex.quote(_CLAUDE_BIN)}{model_arg} "$(cat {shlex.quote(str(prompt_file))})"' + session = f"{DAEMON_SESSION_PREFIX}{branch_path.name}-{time.strftime('%H%M%S')}" + claude_line = ( + f"{shlex.quote(_CLAUDE_BIN)} --model {shlex.quote(model)} " + f"--permission-mode bypassPermissions " + f'"$(cat {shlex.quote(str(prompt_file))})"' + ) try: subprocess.run( ["tmux", "new-session", "-d", "-s", session, "-c", str(branch_path)], @@ -602,6 +816,10 @@ def _spawn_manager_interactive( text=True, timeout=15, ) + # Marked BEFORE the agent starts: a window is killable from the moment + # it exists, so a marking that lands after send-keys leaves exactly the + # gap this exists to close. + _mark_daemon_session(session, email, status) subprocess.run( ["tmux", "send-keys", "-t", session, claude_line, "Enter"], check=True, @@ -616,8 +834,8 @@ def _spawn_manager_interactive( return status, False status.ok("spawn", f"Interactive tmux session '{session}' started (attach: tmux attach -t {session})") - logger.info("[wake] %s manager woken interactively in tmux session %s", email, session) - json_handler.log_operation("wake_manager_interactive", {"branch": email, "session": session}) + logger.info("[wake] %s manager woken interactively in tmux session %s on %s", email, session, model) + json_handler.log_operation("wake_manager_interactive", {"branch": email, "session": session, "model": model}) return status, True @@ -723,11 +941,15 @@ def wake_branch( # Dispatch/manual wakes remain blocked. passport_file = branch_path / ".trinity" / "passport.json" manager_scheduled = False # daemon-scheduled manager wake → interactive tmux spawn + # Bound before the try so the model policy below reads a defined value on + # every path. An unreadable passport means "" — not a manager, the same + # direction the gate itself already fails in. + citizen_class = "" try: with open(passport_file, "r", encoding="utf-8") as f: passport = json.load(f) citizen_class = passport.get("identity", {}).get("citizen_class", "") - if citizen_class == "manager": + if citizen_class == MANAGER_CLASS: if scheduled: status.ok("manager", f"{email} manager gate bypassed — scheduled wake") status.ok("scheduled", "Headless lane — dispatch_monitor pipeline (context pin applies)") @@ -753,6 +975,14 @@ def wake_branch( except (FileNotFoundError, json.JSONDecodeError, OSError) as exc: logger.info("[wake] Could not read passport for %s: %s", email, exc) + # Step 3b: Model policy, decided ONCE for both spawn lanes. Resolved here + # rather than at each spawn site because the passport this gate just read is + # the ruling's only input — asking the interactive lane to answer it again + # would put the managers-only-Fable rule in two places, and @vera reached + # Fable by CLI accident precisely because no site owned the answer. + resolved_model = resolve_wake_model(citizen_class, model) + status.ok("model", f"{resolved_model} ({citizen_class or 'unclassified'})") + # Step 4: Zombie check (pre-flight) zombie_count = _clean_zombies() if zombie_count > 0: @@ -789,15 +1019,12 @@ def wake_branch( # of the -p/monitor pipeline below. The scheduled lane deliberately does not # set this flag — an unattended run belongs in the monitored pipeline. if manager_scheduled: - return _spawn_manager_interactive(branch_path, email, custom_message or DEFAULT_PROMPT, model, status) + return _spawn_manager_interactive(branch_path, email, custom_message or DEFAULT_PROMPT, resolved_model, status) # Step 7: Build spawn command config = _load_config() max_turns = config.get("max_turns_per_wake", 100) - # Pass model directly to CLI — aliases resolve latest-in-class automatically - resolved_model = model or DEFAULT_MODEL - if custom_message: prompt = f"Hi. {custom_message} " else: diff --git a/src/aipass/ai_mail/apps/handlers/email/header.py b/src/aipass/ai_mail/apps/handlers/email/header.py index e99b7999d..c8c9c851a 100644 --- a/src/aipass/ai_mail/apps/handlers/email/header.py +++ b/src/aipass/ai_mail/apps/handlers/email/header.py @@ -1,22 +1,28 @@ # =================== AIPass ==================== # Name: header.py # Description: Email Header Handler -# Version: 1.2.0 +# Version: 1.2.1 # Created: 2026-02-04 -# Modified: 2026-08-11 +# Modified: 2026-08-31 # ============================================= - -from aipass.ai_mail.apps.handlers.json import json_handler - """ Email Header Handler Generates and prepends headers to outgoing emails. Critical dispatch header ensures agents see memory update reminder FIRST. Independent handler - no module dependencies. + +This string OPENS the module body, before the import. It sat after the import +until 2026-08-31, where Python evaluates it, discards it, and leaves __doc__ +None — the file read as documented to every human and to nothing that reads +__doc__ (help(), pydoc, @seedgo's old line-scan checker). Found by @seedgo's +rewrite onto ast.get_docstring; the position is the whole point, so keep any +new import BELOW this string. """ +from aipass.ai_mail.apps.handlers.json import json_handler + # Header for dispatch emails - agents see this FIRST before the task DISPATCH_HEADER = """⚡ DISPATCH TASK - READ THIS FIRST ⚡ diff --git a/src/aipass/ai_mail/apps/handlers/email/reply.py b/src/aipass/ai_mail/apps/handlers/email/reply.py index 398c955dc..91f416a41 100644 --- a/src/aipass/ai_mail/apps/handlers/email/reply.py +++ b/src/aipass/ai_mail/apps/handlers/email/reply.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: reply.py # Description: Email Reply Handler -# Version: 1.1.0 +# Version: 1.1.1 # Created: 2025-11-30 -# Modified: 2026-08-07 +# Modified: 2026-08-31 # ============================================= """ @@ -22,6 +22,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger from aipass.ai_mail.apps.handlers.json import json_handler +from aipass.ai_mail.apps.handlers.paths import registries_in from aipass.ai_mail.apps.handlers.email.delivery import deliver_to_inbox_file from aipass.ai_mail.apps.handlers.dispatch.report import stamp_dispatch_id @@ -221,7 +222,7 @@ def _validate_reply_path(reply_path: str) -> Tuple[bool, str]: return False, f"Path does not end with .ai_mail.local/inbox.json: {path}" for parent in path.parents: - if any(parent.glob("*_REGISTRY.json")): + if registries_in(parent): return True, "" if parent == parent.parent: break diff --git a/src/aipass/ai_mail/apps/handlers/paths.py b/src/aipass/ai_mail/apps/handlers/paths.py index 2662d663d..eab55ad2c 100644 --- a/src/aipass/ai_mail/apps/handlers/paths.py +++ b/src/aipass/ai_mail/apps/handlers/paths.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: paths.py # Description: Shared path utilities for ai_mail handlers -# Version: 1.0.0 +# Version: 1.1.0 # Created: 2026-03-29 -# Modified: 2026-03-29 +# Modified: 2026-08-31 # ============================================= """ @@ -41,6 +41,70 @@ # available on a fresh checkout; it was simply never asked for. REPO_ROOT_MARKERS = ("AIPASS_REGISTRY.json", "pyproject.toml") +# The suffix a registry file must END with, case exactly as written. +# +# SUFFIX, never the stem. External projects name their registry after +# themselves — Vera-Studio_REGISTRY.json, vera_studio_REGISTRY.json — so a check +# keyed on the stem would delete real citizens in order to fix this bug. +REGISTRY_SUFFIX = "_REGISTRY.json" +REGISTRY_GLOB = f"*{REGISTRY_SUFFIX}" + + +def registries_in(directory: Path, pattern: str = REGISTRY_GLOB) -> list: + """Registry files under *directory*, with the NAME re-checked case-sensitively. + + The one filtered reader every registry walk in this branch goes through. + ``pathlib`` delegates matching to the filesystem, so on Windows and default + macOS ``*_REGISTRY.json`` also matches ``*_registry.json`` — and this repo is + full of bait: ``drone_command_registry.json`` sits beside drone's tree, every + branch carries ``.spawn/.template_registry.json`` (pathlib ``*`` matches + dotfiles, unlike the ``glob`` module), and @flow keeps ten + ``flow_json/*_registry.json`` plan counters. Measured on CI, ``find_registry`` + returned a command table as the trust-anchor candidate (@drone, ef029782). + + The glob still does the walking — only the filesystem knows where the files + are. What it cannot be trusted with is the ANSWER, so the name is compared + again here in Python, where case means what it says. + + Why this matters in ai_mail specifically: these walks are identity-bearing. + They answer "which project is this" for the cross-project delivery fence and + "which registry names the caller" for branch detection. A counter file + accepted as a registry is the directory-name-as-identity species arriving + through a different door. + + Args: + directory: Where to look. + pattern: Glob pattern, defaulting to one level. Callers pass a deeper + pattern (``*/*_REGISTRY.json``) for tree discovery; the SUFFIX check + below is applied whatever the depth, which is the whole point of + routing every depth through one function. + + Returns: + Sorted list of matching paths. Sorted for a deterministic pick when a + directory holds several — callers that only want existence read the + list's truthiness. + """ + try: + found = list(directory.glob(pattern)) + except OSError as exc: + logger.warning("[paths] registries_in: glob failed at %s: %s", directory, exc) + return [] + + kept = [] + for candidate in found: + if candidate.name.endswith(REGISTRY_SUFFIX): + kept.append(candidate) + else: + # Named, never silent. On Linux this branch is unreachable and the + # log stays empty; on Windows it is the only record that the + # filesystem handed back something the pattern did not ask for. + logger.info( + "[paths] registries_in: %s refused — name does not end with %s (case-insensitive filesystem)", + candidate.name, + REGISTRY_SUFFIX, + ) + return sorted(kept) + def find_repo_root() -> Path: """Walk up from this file to the repo root, by the first marker that answers. @@ -102,17 +166,24 @@ def find_repo_root() -> Path: def find_project_root(start: Path) -> Optional[Path]: """Walk up from *start* to find the first *_REGISTRY.json (project root). + Reads through :func:`registries_in`, so a lowercase counter file cannot be + mistaken for a registry on a case-insensitive filesystem. This answer feeds + the cross-project delivery fence (``delivery.py``), where a wrong root makes + the fence compare two different questions and refuse ordinary same-project + mail. + + One behaviour change came with that: an unreadable directory used to abort + the whole walk and return None. It now skips that level and keeps climbing — + a registry found higher up is still a real registry, and for the fence the + new direction fails CLOSED (a found root can refuse; None always allows). + Returns the directory containing the registry, or None if not found. Stops at filesystem root. """ current = start.resolve() for candidate in [current] + list(current.parents): - try: - if any(candidate.glob("*_REGISTRY.json")): - return candidate - except OSError as exc: - logger.warning("[paths] find_project_root: glob failed at %s: %s", candidate, exc) - break + if registries_in(candidate): + return candidate return None diff --git a/src/aipass/ai_mail/apps/handlers/registry/read.py b/src/aipass/ai_mail/apps/handlers/registry/read.py index 17bf934c1..511617021 100644 --- a/src/aipass/ai_mail/apps/handlers/registry/read.py +++ b/src/aipass/ai_mail/apps/handlers/registry/read.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: read.py # Description: Registry Read Handler -# Version: 1.1.0 +# Version: 1.2.0 # Created: 2025-11-15 -# Modified: 2026-08-12 +# Modified: 2026-08-31 # ============================================= """ @@ -28,7 +28,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger from aipass.ai_mail.apps.handlers.json import json_handler -from aipass.ai_mail.apps.handlers.paths import find_repo_root +from aipass.ai_mail.apps.handlers.paths import find_repo_root, registries_in if sys.platform == "win32": os.environ.setdefault("PYTHONUTF8", "1") @@ -198,6 +198,7 @@ def _branches_from_registry(reg_file: Path) -> Dict[str, str]: # refused. The rule is named so it can be read, not buried in a glob string. RESIDENT_PROJECTS_DIR = "projects" RESIDENT_REGISTRY_GLOB = "*/*_REGISTRY.json" +PROJECT_TREE_REGISTRY_GLOB = f"{RESIDENT_PROJECTS_DIR}/{RESIDENT_REGISTRY_GLOB}" PASSPORT_RELATIVE = Path(".trinity") / "passport.json" # The two values passport 2.0 defines for citizenship.residency. @@ -263,7 +264,7 @@ def resident_registry_paths(repo_root: Path) -> List[Path]: return [] found: List[Path] = [] - for path in sorted(projects.glob(RESIDENT_REGISTRY_GLOB)): + for path in registries_in(projects, RESIDENT_REGISTRY_GLOB): if any(part.startswith(".") for part in path.relative_to(projects).parts): continue found.append(path) @@ -392,7 +393,7 @@ def get_project_tree_branches(repo_root: Path) -> Dict[str, str]: Empty dict when there is no projects/ tree. """ result: Dict[str, str] = {} - for reg_file in sorted(Path(repo_root).glob("projects/*/*_REGISTRY.json")): + for reg_file in registries_in(Path(repo_root), PROJECT_TREE_REGISTRY_GLOB): result.update(_branches_from_registry(reg_file)) return result @@ -415,7 +416,7 @@ def get_caller_project_branches(caller_cwd: str) -> Dict[str, str]: """ current = Path(caller_cwd).resolve() for _ in range(10): - for reg_file in current.glob("*_REGISTRY.json"): + for reg_file in registries_in(current): result = _branches_from_registry(reg_file) if result: return result diff --git a/src/aipass/ai_mail/apps/handlers/users/branch_detection.py b/src/aipass/ai_mail/apps/handlers/users/branch_detection.py index c8bb2ec6c..1a7c2d35a 100644 --- a/src/aipass/ai_mail/apps/handlers/users/branch_detection.py +++ b/src/aipass/ai_mail/apps/handlers/users/branch_detection.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: branch_detection.py # Description: Branch Auto-Detection Handler -# Version: 1.3.0 +# Version: 1.3.1 # Created: 2025-11-18 -# Modified: 2026-08-21 +# Modified: 2026-08-31 # ============================================= """ @@ -24,7 +24,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger from aipass.ai_mail.apps.handlers.json import json_handler -from aipass.ai_mail.apps.handlers.paths import find_repo_root +from aipass.ai_mail.apps.handlers.paths import find_repo_root, registries_in if sys.platform == "win32": os.environ.setdefault("PYTHONUTF8", "1") @@ -136,7 +136,7 @@ def _find_caller_registry() -> Optional[Path]: candidate = Path(caller_cwd) aipass_registry = BRANCH_REGISTRY_PATH.resolve() for path in [candidate] + list(candidate.parents)[:10]: - for registry in sorted(path.glob("*_REGISTRY.json")): + for registry in registries_in(path): try: if registry.resolve() != aipass_registry: return registry diff --git a/src/aipass/ai_mail/tests/test_external_tier_resolution.py b/src/aipass/ai_mail/tests/test_external_tier_resolution.py new file mode 100644 index 000000000..fa88b07f4 --- /dev/null +++ b/src/aipass/ai_mail/tests/test_external_tier_resolution.py @@ -0,0 +1,349 @@ +# =================== AIPass ==================== +# Name: test_external_tier_resolution.py +# Description: resolve_branch reaches the declared-roots external tier (FPLAN-0460) +# Version: 1.0.0 +# Created: 2026-08-30 +# Modified: 2026-08-30 +# ============================================= + +"""The external tier, through my door. + +@vera's first supervised fire failed at ``resolve: Branch not found: @vera``. +Job DISCOVERY already knew the tier — @daemon reads @memory's fleet gateway — +but the FIRE path came through ``wake.resolve_branch``, which knew three +sources and none of them looked outside this repo. Discoverable but not +wakeable: the tier was half-plumbed. + +THE SEAM IS ``_REPO_ROOT``, not a patched gateway. @memory's +``external_branches(repo_root=...)`` reads ``AIPASS_ROOTS.json`` at whatever +root it is handed, so pointing wake's ``_REPO_ROOT`` at a tmp home gives both +world states — anchor present and anchor absent — through the REAL gateway +code. Patching the gateway instead would have proven only that my mock returns +what I told it to, and the live defect was in whether the call happens at all. +Nothing here reads the real machine; every root, registry and passport below is +built in tmp_path. +""" + +import json +from pathlib import Path + +import pytest + +import aipass.ai_mail.apps.handlers.dispatch.wake as wake_mod + + +@pytest.fixture(autouse=True) +def _clear_caller_env(monkeypatch): + """Step 2 must not reach a real project registry from whoever ran the suite.""" + monkeypatch.delenv("AIPASS_CALLER_BRANCH", raising=False) + monkeypatch.delenv("AIPASS_CALLER_CWD", raising=False) + + +def _citizen(root: Path, rel: str, email: str) -> Path: + """A branch on disk with the one thing external membership requires: a passport.""" + path = root / rel + (path / ".trinity").mkdir(parents=True) + (path / ".trinity" / "passport.json").write_text( + json.dumps({"branch_info": {"branch_name": path.name, "email": email}}), + encoding="utf-8", + ) + return path + + +def _registry(root: Path, name: str, rows: list) -> None: + """A top-level ``*_REGISTRY.json`` — the only thing the external walk globs.""" + (root / name).write_text(json.dumps({"branches": rows}), encoding="utf-8") + + +@pytest.fixture +def home(tmp_path, monkeypatch): + """AIPass home, with one local citizen and no declared roots yet.""" + home = tmp_path / "AIPass" + home.mkdir() + local = home / "src" / "aipass" / "ai_mail" + local.mkdir(parents=True) + _registry(home, "AIPASS_REGISTRY.json", [{"name": "AI_MAIL", "email": "@ai_mail", "path": "src/aipass/ai_mail"}]) + monkeypatch.setattr(wake_mod, "_REPO_ROOT", home) + monkeypatch.setattr(wake_mod, "BRANCH_REGISTRY", home / "AIPASS_REGISTRY.json") + return home + + +@pytest.fixture +def vera_studio(tmp_path): + """A sibling repo with its own registry and two citizens — @vera among them.""" + root = tmp_path / "Vera-Studio" + root.mkdir() + vera = _citizen(root, "src/vera_studio/vera", "@vera") + _citizen(root, "src/vera_studio/writer", "@writer") + _registry( + root, + "VERA-STUDIO_REGISTRY.json", + [ + {"name": "vera", "email": "@vera", "status": "active", "path": str(vera)}, + {"name": "writer", "email": "@writer", "status": "active", "path": str(root / "src/vera_studio/writer")}, + ], + ) + return root + + +def _declare(home: Path, *roots: Path, status: str = "active") -> None: + """Write the machine anchor. Relative paths, as the live file carries them.""" + (home / "AIPASS_ROOTS.json").write_text( + json.dumps( + {"roots": [{"path": f"../{root.name}", "label": root.name.lower(), "status": status} for root in roots]} + ), + encoding="utf-8", + ) + + +class TestExternalTierIsReachable: + """The defect and its fix, in the two world states.""" + + def test_external_citizen_unreachable_without_the_anchor(self, home, vera_studio): + """WORLD STATE B — no AIPASS_ROOTS.json. @vera stays unresolvable. + + The pre-tier behaviour, pinned. An installation that declares nothing + participates in nothing, and that is the ordinary state of a fresh + clone rather than a fault. + """ + assert not (home / "AIPASS_ROOTS.json").exists() + + assert wake_mod.resolve_branch("@vera") is None + + def test_external_citizen_resolves_through_the_anchor(self, home, vera_studio): + """WORLD STATE A — the root is declared. @vera resolves to its real path. + + This is the live failure: same call, same tree, one blessed file apart. + """ + _declare(home, vera_studio) + + result = wake_mod.resolve_branch("@vera") + + assert result is not None + path, email = result + assert email == "@vera" + assert path == vera_studio / "src" / "vera_studio" / "vera" + + def test_resolution_is_case_insensitive_across_the_fence(self, home, vera_studio): + """@VERA is @vera. The canonical lowercase address comes back either way.""" + _declare(home, vera_studio) + + result = wake_mod.resolve_branch("@VERA") + + assert result is not None + assert result[1] == "@vera" + + def test_the_registry_side_is_case_folded_too(self, home, tmp_path): + """BOTH sides fold, and only this pins the far one. + + The caller's input is lowercased at the top of resolve_branch, so a + fixture that varies only the INPUT proves nothing about the address + stored in someone else's registry. A dropped ``.lower()`` on the + citizen's email survived that test and dies against this one. Casing + genuinely disagrees across the fleet — the core registry already spells + BACKUP and backup — and an external repo owes us no convention at all. + """ + root = tmp_path / "Shouty" + root.mkdir() + seat = _citizen(root, "src/loud", "@Loud") + _registry( + root, "SHOUTY_REGISTRY.json", [{"name": "loud", "email": "@LOUD", "status": "active", "path": str(seat)}] + ) + _declare(home, root) + + result = wake_mod.resolve_branch("@loud") + + assert result is not None + assert result[0] == seat + assert result[1] == "@loud" + + def test_a_root_declared_inactive_is_not_consulted(self, home, vera_studio): + """``status`` is the anchor's own off switch and it survives my step.""" + _declare(home, vera_studio, status="parked") + + assert wake_mod.resolve_branch("@vera") is None + + def test_an_unknown_external_address_still_returns_none(self, home, vera_studio): + """The tier widens WHO resolves, never WHETHER a miss is a miss.""" + _declare(home, vera_studio) + + assert wake_mod.resolve_branch("@ghost") is None + + def test_a_registry_row_without_a_passport_is_refused(self, home, tmp_path): + """Membership is PRESENCE — a registry may list what disk does not carry.""" + root = tmp_path / "Hollow" + root.mkdir() + _registry( + root, + "HOLLOW_REGISTRY.json", + [{"name": "ghosted", "email": "@ghosted", "status": "active", "path": str(root / "src" / "ghosted")}], + ) + (root / "src" / "ghosted").mkdir(parents=True) + _declare(home, root) + + assert wake_mod.resolve_branch("@ghosted") is None + + def test_an_addressless_citizen_does_not_break_the_search(self, home, tmp_path): + """A registry row may carry no email at all, and it must not crash the walk. + + @memory passes the address through rather than deriving one from the + directory name, so ``email`` is legitimately None for a path-only + citizen. Comparing ``None.lower()`` would raise, be swallowed by the + containment guard, and turn every OTHER external in that root into an + unresolvable — one addressless row taking out a whole tier. + """ + root = tmp_path / "Quiet" + root.mkdir() + mute = _citizen(root, "src/mute", "@mute") + named = _citizen(root, "src/named", "@named") + _registry( + root, + "QUIET_REGISTRY.json", + [ + {"name": "mute", "status": "active", "path": str(mute)}, + {"name": "named", "email": "@named", "status": "active", "path": str(named)}, + ], + ) + _declare(home, root) + + assert wake_mod.resolve_branch("@mute") is None + result = wake_mod.resolve_branch("@named") + assert result is not None + assert result[0] == named + + +class TestLocalAlwaysWins: + """Precedence: the external tier is consulted only after every local source misses.""" + + def test_aipass_registry_preempts_the_external_tier(self, home, tmp_path): + """A name in both places resolves LOCALLY. The tier is a fallback, not a preempt.""" + impostor = tmp_path / "Impostor" + impostor.mkdir() + clash = _citizen(impostor, "src/ai_mail", "@ai_mail") + _registry( + impostor, + "IMPOSTOR_REGISTRY.json", + [{"name": "ai_mail", "email": "@ai_mail", "status": "active", "path": str(clash)}], + ) + _declare(home, impostor) + + result = wake_mod.resolve_branch("@ai_mail") + + assert result is not None + assert result[0] == home / "src" / "aipass" / "ai_mail" + + def test_caller_project_registry_preempts_the_external_tier(self, home, tmp_path, monkeypatch): + """Step 2 wins too — the ruling is 'local always wins', not 'the core registry wins'.""" + caller_home = tmp_path / "CallerProject" + caller_seat = caller_home / "src" / "strategy" + caller_seat.mkdir(parents=True) + external = tmp_path / "Elsewhere" + external.mkdir() + far = _citizen(external, "src/strategy", "@strategy") + _registry( + external, + "ELSEWHERE_REGISTRY.json", + [{"name": "strategy", "email": "@strategy", "status": "active", "path": str(far)}], + ) + _declare(home, external) + + import aipass.ai_mail.apps.handlers.registry.read as read_mod + + monkeypatch.setattr(read_mod, "get_caller_project_branches", lambda cwd: {"@strategy": str(caller_seat)}) + monkeypatch.setenv("AIPASS_CALLER_CWD", str(caller_seat)) + + result = wake_mod.resolve_branch("@strategy") + + assert result is not None + assert result[0] == caller_seat + + +class TestTheStepIsContained: + """A tier that cannot answer must never take the resolver down with it.""" + + def test_a_raising_gateway_returns_none_not_a_traceback(self, home, vera_studio, monkeypatch): + """Every caller of resolve_branch expects None on a miss, never an exception.""" + _declare(home, vera_studio) + + def _boom(*_args, **_kwargs): + raise RuntimeError("gateway down") + + monkeypatch.setattr(wake_mod, "_external_citizens", _boom) + + assert wake_mod.resolve_branch("@vera") is None + + def test_the_external_step_needs_no_admin_grant(self, home, vera_studio): + """The daemon fires unverified. Declaration is the credential, not a grant. + + Deliberate and stated: the anchor is a machine-managed file Patrick + blessed, so an external root is already an authorised destination. The + admin sweep of ``projects/*`` is a different question and stays gated. + """ + _declare(home, vera_studio) + + assert wake_mod.resolve_branch("@vera", admin=False) is not None + + +class TestCollisionsAreNamedNotGuessed: + """N-root ties: one answer, and the ambiguity said out loud.""" + + def _twin_in(self, tmp_path, name): + """A root called `name` holding one citizen at @twin.""" + root = tmp_path / name + root.mkdir() + seat = _citizen(root, "src/twin", "@twin") + _registry( + root, + f"{name.upper()}_REGISTRY.json", + [{"name": "twin", "email": "@twin", "status": "active", "path": str(seat)}], + ) + return root + + def test_the_first_DECLARED_root_wins_not_the_first_alphabetically(self, home, tmp_path, caplog): + """The fleet's tie-break, proven end to end through @memory's gateway. + + This pin used to assert the opposite. ``declared_roots()`` returned + ``sorted(found)`` — resolved-path order — so the winner was whatever + someone happened to name a directory, and the tie-break the ruling names + was not available at this door. Rather than re-read the anchor to + recover it (a second reader of the file the gateway exists to own), the + collision was made loud and the disagreement raised with @memory, who + dropped the sort in registry_scope 4.1.0. Zulu is declared first and + sorts last, so only declaration order can produce this answer. + """ + alpha = self._twin_in(tmp_path, "Alpha") + zulu = self._twin_in(tmp_path, "Zulu") + _declare(home, zulu, alpha) + + result = wake_mod.resolve_branch("@twin") + + assert result is not None + assert result[0] == zulu / "src" / "twin" + + def test_the_control_case_where_both_orders_agree(self, home, tmp_path, caplog): + """Declared Alpha-first, which is ALSO alphabetical — so this passes even + under a re-sorted gateway. It is here as the twin of the test above: + alone it proves nothing, and together they separate the two orders.""" + alpha = self._twin_in(tmp_path, "Alpha") + zulu = self._twin_in(tmp_path, "Zulu") + _declare(home, alpha, zulu) + + result = wake_mod.resolve_branch("@twin") + + assert result is not None + assert result[0] == alpha / "src" / "twin" + + def test_every_losing_claimant_is_named_in_the_log(self, home, tmp_path, caplog): + """A tie-break being correct does not make a collision expected. Two + roots claiming one address is worth an error line naming both, or a + resolution nobody logged reads exactly like a citizen with one home.""" + alpha = self._twin_in(tmp_path, "Alpha") + zulu = self._twin_in(tmp_path, "Zulu") + _declare(home, zulu, alpha) + + result = wake_mod.resolve_branch("@twin") + + assert result is not None + assert "@twin" in caplog.text + assert str(alpha / "src" / "twin") in caplog.text, "the loser must be named, not just the winner" + assert "DECLARATION ORDER" in caplog.text diff --git a/src/aipass/ai_mail/tests/test_handlers_guard_import.py b/src/aipass/ai_mail/tests/test_handlers_guard_import.py new file mode 100644 index 000000000..f852d9ea4 --- /dev/null +++ b/src/aipass/ai_mail/tests/test_handlers_guard_import.py @@ -0,0 +1,336 @@ +# =================== AIPass ==================== +# Name: test_handlers_guard_import.py +# Description: The branch-access guard must import without a readable cwd +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""The import-time branch guard must not need a filesystem to run. + +THE DEFECT (@spawn's find, 2026-08-31, 16 branches carried it). ``_find_real_caller`` +opened with ``inspect.stack()``, which builds a FrameInfo per frame and reaches +``getsourcefile() -> getmodule() -> os.path.realpath()``. On Windows +``ntpath.realpath`` calls ``os.getcwd()`` unconditionally in its opening lines — +before it checks whether the path is even absolute — at a call site inside +``getmodule`` that is not wrapped in a try. So importing ANY handler in this +package needed a readable cwd on Windows, and a disconnected share killed the +import of a package whose only job at that moment was to check a name. + +WHY IT HID ON LINUX, and why these pins deny what they deny. ``posixpath.realpath`` +does not call ``getcwd`` for an absolute path, so the POSIX equivalent raises +earlier inside ``getabsfile()`` where ``inspect`` catches it. Denying +``os.getcwd`` on Linux therefore proves nothing here — measured, both ways, before +these pins were written. The instrument denies ``os.path.realpath``: the call the +defect actually makes. + +Everything runs in a SUBPROCESS because the thing under test happens at import +time, and a package already in ``sys.modules`` cannot be imported again. +""" + +import ast +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[4] +TARGET = "aipass.ai_mail.apps.handlers" + + +def _run(*parts: str) -> subprocess.CompletedProcess: + """Run the dedented *parts* in a fresh interpreter rooted at the repo. + + Each part is dedented SEPARATELY and then joined. Dedenting the concatenation + takes the common prefix across both, which silently leaves the second block + over-indented — my first cut did exactly that and the positive control failed + with IndentationError rather than with the defect it was meant to reproduce. + """ + body = "\n".join(textwrap.dedent(part) for part in parts) + return subprocess.run( + [sys.executable, "-c", body], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=120, + ) + + +DENY_REALPATH = """ + import os, os.path + def _denied(*a, **k): + raise OSError(9, "realpath denied - emulating a host with no readable cwd") + os.path.realpath = _denied +""" + + +class TestTheInstrumentBites: + """Controls first. A denial that denies nothing turns the pin below green + for the wrong reason.""" + + def test_positive_control_inspect_stack_dies_under_the_denial(self): + """Exercises the MECHANISM the old guard used — ``inspect.stack()`` + itself — not a re-implementation of it. If this passes, the instrument + is not reproducing the defect and the pin below is measuring nothing.""" + result = _run( + DENY_REALPATH, + """ + import inspect + try: + inspect.stack() + print("SURVIVED") + except OSError: + print("DIED") + """, + ) + assert "DIED" in result.stdout, f"instrument did not bite: {result.stdout!r} {result.stderr!r}" + + def test_negative_control_inspect_stack_is_fine_without_the_denial(self): + """The same call, instrument NOT installed. If this also dies, something + other than the denial is doing the work.""" + result = _run( + """ + import inspect + try: + inspect.stack() + print("SURVIVED") + except OSError: + print("DIED") + """ + ) + assert "SURVIVED" in result.stdout, f"{result.stdout!r} {result.stderr!r}" + + +class TestTheGuardImportsWithoutAFilesystem: + def test_importing_handlers_survives_a_denied_realpath(self): + """The pin. Red against the pre-fix guard on THIS machine — measured, not + assumed: the old shape returned ``OSError: [Errno 9] denied`` from this + exact harness before the cure.""" + result = _run(DENY_REALPATH, f"import {TARGET}\nprint('IMPORT OK')") + assert "IMPORT OK" in result.stdout, f"the branch guard still needs a filesystem to run:\n{result.stderr}" + + def test_importing_handlers_survives_a_denied_getcwd(self): + """The dead-cwd world from the other direction. Passes before AND after + the cure on Linux — kept because it is the world Windows actually + presents, and its silence here is the evidence that ``getcwd`` was never + the right thing to deny on this platform.""" + result = _run( + """ + import os + def _denied(*a, **k): + raise OSError(2, "no cwd") + os.getcwd = _denied + """, + f"import {TARGET}\nprint('IMPORT OK')", + ) + assert "IMPORT OK" in result.stdout, result.stderr + + def test_the_ordinary_import_still_works(self): + """A guard that refused everything would pass every denial test above.""" + result = _run(f"import {TARGET}\nprint('IMPORT OK')") + assert "IMPORT OK" in result.stdout, result.stderr + + +class TestTheCureCannotBeSilentlyReverted: + """``inspect.stack()`` is the defect, not a style preference. The ban is on + the call, so a revert goes red here rather than on the next Windows train.""" + + SOURCE = Path(__file__).resolve().parents[1] / "apps" / "handlers" / "__init__.py" + + def test_the_guard_source_is_actually_read(self): + """Positive control for the two greps below: a wrong path would let both + pass while reading nothing.""" + text = self.SOURCE.read_text(encoding="utf-8") + assert "_find_real_caller" in text and "_guard_branch_access" in text + + def test_inspect_stack_is_gone_from_the_guard(self): + """AST, not grep. The first cut asserted the STRING was absent and went + red against a cured file — it was matching the explanation of the defect + in the guard's own docstring. Banning a spelling instead of a shape gets + you a rule that convicts prose and acquits code.""" + tree = ast.parse(self.SOURCE.read_text(encoding="utf-8"), filename=str(self.SOURCE)) + calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "stack" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "inspect" + ] + assert calls == [], ( + "inspect.stack() reaches os.path.realpath, which needs a readable cwd on " + f"Windows — walk frames with sys._getframe instead (line {[c.lineno for c in calls]})" + ) + + def test_that_ban_would_convict_a_real_call(self): + """Negative control for the rule above. An AST matcher that matches + nothing passes on every file, including a reverted one.""" + tree = ast.parse("import inspect\ndef f():\n return inspect.stack()\n") + found = [ + n + for n in ast.walk(tree) + if isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and n.func.attr == "stack" + and isinstance(n.func.value, ast.Name) + and n.func.value.id == "inspect" + ] + assert len(found) == 1 + + def test_the_frame_walk_is_what_replaced_it(self): + """Names the cure, so 'removed inspect and broke the guard' is not a way + to make the test above pass.""" + text = self.SOURCE.read_text(encoding="utf-8") + assert "sys._getframe" in text + assert "linecache" in text, "code_context needs a replacement, not deletion" + + +class TestThePolicyDidNotMoveWithTheMechanism: + """The mechanism changed; who is allowed in must not have. These call the + guard's own helpers directly rather than through an import, so they measure + the decision instead of the plumbing.""" + + def test_an_external_caller_is_still_classified_as_external(self): + from aipass.ai_mail.apps.handlers import _extract_branch_name + + assert _extract_branch_name("/home/x/src/aipass/devpulse/apps/thing.py") == "devpulse" + assert _extract_branch_name("/home/x/src/aipass/ai_mail/apps/thing.py") == "ai_mail" + + def test_an_unrecognisable_path_is_unknown_not_a_branch(self): + from aipass.ai_mail.apps.handlers import _extract_branch_name + + assert _extract_branch_name("/tmp/nowhere/thing.py") == "unknown" + + @pytest.mark.parametrize( + "caller,allowed", + [ + ("/home/x/src/aipass/ai_mail/apps/handlers/paths.py", True), + ("C:\\src\\aipass\\ai_mail\\apps\\thing.py", True), + ("/home/x/src/aipass/devpulse/apps/thing.py", False), + ("/home/x/src/aipass/memory/apps/thing.py", False), + ], + ) + def test_the_membership_rule_is_unchanged_and_separator_agnostic(self, caller, allowed): + """The rule the guard applies: is ``/ai_mail/`` in the caller path, with + backslashes normalised first. Windows spellings included because the OS + that found the import defect is the one that spells paths the other way.""" + from aipass.ai_mail.apps.handlers import MY_BRANCH + + assert (f"/{MY_BRANCH}/" in caller.replace("\\", "/")) is allowed + + +class TestLinecacheSwallowsItsOwnErrors: + """Why the ``except OSError`` around ``linecache.getline`` survives mutation. + + Removing it changes nothing under every denial these tests can build, and + that is not a missing pin — the except is UNREACHABLE, because linecache + catches its own errors and returns "". A survivor that is explained by + measurement is worth more than one explained by a comment, so the assumption + the defensive code rests on is pinned here. If a future runtime starts + raising, this goes red and that ``except`` becomes load-bearing. + """ + + TARGET = str(Path(__file__).resolve().parents[1] / "apps" / "handlers" / "paths.py") + + @pytest.mark.parametrize("module,attr", [("os", "stat"), ("tokenize", "open"), ("io", "open_code")]) + def test_getline_returns_empty_rather_than_raising(self, module, attr): + result = _run( + f""" + import importlib, linecache + mod = importlib.import_module({module!r}) + def _denied(*a, **k): + raise OSError(9, "denied") + setattr(mod, {attr!r}, _denied) + linecache.clearcache() + try: + line = linecache.getline({self.TARGET!r}, 1) + print("RETURNED", repr(line)[:20]) + except OSError: + print("RAISED") + """ + ) + assert "RAISED" not in result.stdout, ( + f"linecache now raises when {module}.{attr} is denied — the except in " + "_find_real_caller is load-bearing again, and the guard needs a pin for it" + ) + + +class TestTheCallerIsNoneBranchRunsForReal: + """The behavioural sibling to the AST ban, per @spawn's correction relayed + 2026-08-31: the deleted second ``inspect.stack()`` walk is unreachable from + IMPORT-shaped pins, not unreachable full stop. + + ``apps/__init__.py`` always supplies a real-file frame, so importing the + package can never drive ``caller_file is None``. Calling + ``_guard_branch_access()`` DIRECTLY from a ``python -c`` child can: every + frame is a string-pseudo or importlib, both skipped, so ``_find_real_caller`` + returns None and the branch runs. A regrown walk dies there under a realpath + denial; the cured plain ``return`` survives. + + ``-c`` and never a script file: @commons' lesson — a world spelled too + realistically is silently inert, because running the probe as a script makes + every frame a real on-disk file and ``getsourcefile`` early-returns. + """ + + ARM_AND_CALL = """ + import os, os.path, sys + _real_realpath = os.path.realpath + + # ARMING PROBE 1 — the denial actually bites. Without this the whole test + # can pass in a world where nothing was ever denied. + def _denied(*a, **k): + raise OSError(9, "realpath denied") + os.path.realpath = _denied + import inspect + try: + inspect.stack() + print("ARM1:INERT") + except OSError: + print("ARM1:LIVE") + + from aipass.ai_mail.apps.handlers import _find_real_caller, _guard_branch_access + + # ARMING PROBE 2 — the branch under test is the one being exercised. If a + # real frame leaks in, the guard takes the ordinary path and the pin below + # proves nothing about the None branch. + caller, line = _find_real_caller() + print("ARM2:CALLER=" + repr(caller)) + + _guard_branch_access() + print("GUARD:RETURNED") + """ + + def test_the_denial_is_live_and_the_none_branch_is_the_one_reached(self): + """Both arming probes, asserted before the claim that depends on them.""" + result = _run(self.ARM_AND_CALL) + assert "ARM1:LIVE" in result.stdout, f"denial inert: {result.stdout!r} {result.stderr!r}" + assert "ARM2:CALLER=None" in result.stdout, ( + f"a real frame leaked in — the None branch was not exercised: {result.stdout!r}" + ) + + def test_the_guard_returns_instead_of_dying_there(self): + """The claim. Red against a regrown stack walk, green against the cured + plain return.""" + result = _run(self.ARM_AND_CALL) + assert "GUARD:RETURNED" in result.stdout, ( + f"the caller-is-None branch still needs a filesystem:\n{result.stderr}" + ) + + def test_this_negative_control_cannot_fail_for_the_bans_reason(self): + """@spawn's check, applied to my OWN control: their docstring control + asserted the whole live guard file clean, so it was a second copy of the + ban wearing a control's name — restoring the walk redded it too. + + Mine parses a synthetic source string that no edit to the guard can + change, so it can only fail if the matcher itself stops matching. Pinned + so a later 'simplification' to read the live file is caught. + """ + source = Path(__file__).resolve().read_text(encoding="utf-8") + control = source.split("def test_that_ban_would_convict_a_real_call")[1].split("def ")[0] + assert "SOURCE" not in control and "read_text" not in control, ( + "the ban's negative control must not read the live guard file — " + "a control that fails for the ban's reason is a second ban" + ) diff --git a/src/aipass/ai_mail/tests/test_header.py b/src/aipass/ai_mail/tests/test_header.py index c8a970b66..73765aa96 100644 --- a/src/aipass/ai_mail/tests/test_header.py +++ b/src/aipass/ai_mail/tests/test_header.py @@ -1,8 +1,12 @@ """Tests for email header handler -- get_dispatch_header, prepend_dispatch_header.""" +import ast + import pytest from unittest.mock import MagicMock +from pathlib import Path + import aipass.ai_mail.apps.handlers.email.header as mod @@ -147,3 +151,126 @@ def test_culture_fence_reaches_the_recipient(): not just a module constant.""" result = mod.prepend_dispatch_header("Task body.") assert "never invent an author" in result + + +# --- The module docstring is where Python can see it ----------------- +# +# Found by @seedgo (2026-08-31) when they rewrote check_module_docstring off a +# 30-line scan for a triple quote and onto ast.get_docstring. This file's +# docstring sat AFTER the json_handler import. Python evaluates such a string, +# discards it, and leaves __doc__ None — so the module read as documented to +# every human opening it and to nothing that reads __doc__: help(), pydoc, and +# seedgo's new checker. That is why the old line scan agreed with the reader +# instead of with the interpreter, and why the verdict was 100 for eight months. + + +def test_header_module_has_a_real_docstring(): + """The defect itself: __doc__ is populated, not silently discarded. + + Goes red on the pre-fix file — the string was there, ``__doc__`` was None. + """ + assert mod.__doc__ is not None + assert "Email Header Handler" in mod.__doc__ + + +def test_header_docstring_opens_the_module_body(): + """The MECHANISM, separately from the outcome: the string is statement zero. + + ``__doc__`` being populated is the outcome; a string literal opening the + body is the cause. Pinned apart so a future red names which one moved — + a docstring assigned some other way would satisfy the pin above and still + leave the shape seedgo measures wrong. + """ + tree = ast.parse(Path(mod.__file__).read_text(encoding="utf-8")) + first = tree.body[0] + assert isinstance(first, ast.Expr), f"module opens with {type(first).__name__}" + assert isinstance(first.value, ast.Constant) + assert isinstance(first.value.value, str) + + +def _discarded_module_strings(source: str) -> list: + """Line numbers of module-level strings Python evaluates and throws away. + + A bare string expression at the top of the body IS the docstring; anywhere + below it, it is a no-op the interpreter discards. Reports lines only when + the module has no real docstring, which is the shape that reads as + documentation and is not. + """ + tree = ast.parse(source) + if ast.get_docstring(tree) is not None: + return [] + discarded = [ + node.lineno + for index, node in enumerate(tree.body) + if index != 0 + and isinstance(node, ast.Expr) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ] + # An f-string OPENING the body is the same defect one step along: it reads + # as the docstring to a human, ``ast.get_docstring`` refuses it (JoinedStr, + # not Constant), and Python leaves __doc__ None. Named by a mutant here, + # measured at 0 live sites in apps/ on 2026-08-31 — reachable, not armed, + # so the sweep watches for it rather than waiting to be surprised. + if tree.body: + first = tree.body[0] + if isinstance(first, ast.Expr) and isinstance(first.value, ast.JoinedStr): + discarded.append(first.lineno) + return sorted(discarded) + + +_DEFECT_SHAPE = '"""Not the docstring."""' +_CURED_SHAPE = '"""The docstring."""' + + +def test_the_sweep_convicts_the_defect_shape(): + """Negative control. A matcher that convicts nothing reports any tree clean. + + Built from a synthetic source, never the live file, so it cannot pass for + the sweep's own reason — the thing it measures is present here by + construction whatever the tree below does. + """ + guilty = f"import os\n\n{_DEFECT_SHAPE}\n" + assert _discarded_module_strings(guilty) == [3] + + +def test_the_sweep_acquits_a_real_docstring(): + """Positive control. The cured shape — string first, import after — is clean. + + Without this, a matcher that convicts unconditionally would look identical + to a working one from the tree sweep's green. + """ + cured = f"{_CURED_SHAPE}\n\nimport os\n" + assert _discarded_module_strings(cured) == [] + + +def test_the_sweep_convicts_an_fstring_opener(): + """The species M4 named: an f-string reads as the docstring and is not one. + + Zero live sites in ``apps/`` when this was written, so the sweep's green on + this shape says nothing on its own — this control is what makes the tree + result mean something. + """ + guilty = 'f"""Module {1}."""\n\nimport os\n' + assert _discarded_module_strings(guilty) == [1] + + +def test_no_module_in_the_tree_documents_itself_into_the_void(): + """Tree-wide: the species dies everywhere in this branch, not just here. + + seedgo's sweep named one ai_mail file and my own sweep agreed on one — but + a handed list is a starting point, never the scope, so this asks the tree + directly and keeps asking on every run. + """ + apps = Path(__file__).resolve().parents[1] / "apps" + scanned, offenders = 0, [] + for source in apps.rglob("*.py"): + scanned += 1 + lines = _discarded_module_strings(source.read_text(encoding="utf-8")) + if lines: + offenders.append(f"{source.name}:{lines}") + + # Arming probe: a sweep over an empty file list passes for free. A wrong + # root or a renamed apps/ would otherwise read as a clean tree forever. + assert scanned > 30, f"sweep only visited {scanned} files — wrong root?" + assert not offenders, f"module strings Python discards: {offenders}" diff --git a/src/aipass/ai_mail/tests/test_registry_case_sweep.py b/src/aipass/ai_mail/tests/test_registry_case_sweep.py new file mode 100644 index 000000000..16c938cef --- /dev/null +++ b/src/aipass/ai_mail/tests/test_registry_case_sweep.py @@ -0,0 +1,556 @@ +# =================== AIPass ==================== +# Name: test_registry_case_sweep.py +# Description: Registry globs must not widen on a case-insensitive filesystem +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Case-insensitive-filesystem defence for every ``*_REGISTRY.json`` walk. + +THE DEFECT. ``pathlib`` glob delegates matching to the filesystem, so on Windows +and default macOS ``*_REGISTRY.json`` also matches ``*_registry.json``. This repo +is full of bait — 237 lowercase files on this machine at the time of writing: +``drone_command_registry.json`` sits directly beside drone's tree, every branch +carries ``.spawn/.template_registry.json`` (pathlib ``*`` matches dotfiles, unlike +the ``glob`` module), and @flow keeps ten ``flow_json/*_registry.json`` plan +counters. Found on ef029782's windows-setup leg, root-caused by @drone. + +WHY IT MATTERS HERE. My sites are identity-bearing: they answer "which project is +this" and "which registry names the caller". A command table read as a trust +anchor is the directory-name-as-identity species, back through a different door. + +THE INSTRUMENT. These pins run on Linux. ``_case_insensitive_fs`` wraps +``Path.glob`` to also yield the case-folded pattern's matches — @devpulse's shape, +and a faithful emulation of what CI measured, because the bait that exists on disk +is lowercase. Its own honesty is pinned two ways: a POSITIVE control that widens a +listing *through the instrument itself* (never through a re-implementation of its +logic — that mistake cost @aipass a pin that proved nothing while visiting zero +files), and a NEGATIVE control proving the instrument can still say no. +""" + +import ast +import json +import sys +from pathlib import Path + +import pytest + + +REAL_GLOB = Path.glob + + +# ─── Per-platform filesystem expectations ─────────────────────────────── +# +# A MEASUREMENT IS OF AN INSTRUMENT AND A PLATFORM. Round 4 asserted +# `_host_folds_case(...) is False` — a Linux fact stated as a universal — and it +# went red on the real Windows runner (windows-setup, 28ee90d5, run 33431848734). +# The production cure was never wrong; the PIN's premise was. +# +# WHICH HALF IS MEASURED WHERE, stated rather than implied: +# linux — MEASURED LIVE every time this file runs here. +# win32 — DERIVED from that CI red. The failure carried this file's own +# assertion text, which is what makes it a Windows measurement rather +# than a guess. It becomes measured-live the first time this file runs +# GREEN on a Windows box. +# darwin — DELIBERATELY None. See below. +# +# KEYED ON sys.platform, NOT os.name, and this is a considered deviation from the +# fleet shape. os.name collapses darwin into "posix", and default macOS folds +# case — so an os.name table would assert False on a folding host, which is +# EXACTLY the species of error this round exists to fix. macOS is also +# formattable either way, so no fixed expectation is honest there: the row is +# None, meaning "both legal, assert only the link". +_FOLDS_BY_PLATFORM = { + "linux": False, + "win32": True, + "darwin": None, +} + + +def _expected_folding(): + """The table's row for this host, or None where no fixed answer is honest.""" + return _FOLDS_BY_PLATFORM.get(sys.platform, None) + + +@pytest.fixture +def case_insensitive_fs(monkeypatch): + """Make ``Path.glob`` behave as it does on Windows/macOS. + + Yields the real matches, then the matches of the case-folded pattern, without + duplicates. Only the ``_REGISTRY.json`` suffix carries uppercase in any + pattern this branch uses, so folding the whole pattern changes exactly the + thing under test. + """ + + def _folded(self, pattern, *args, **kwargs): + seen = [] + for found in list(REAL_GLOB(self, pattern, *args, **kwargs)) + list( + REAL_GLOB(self, pattern.lower(), *args, **kwargs) + ): + if found not in seen: + seen.append(found) + yield found + + monkeypatch.setattr(Path, "glob", _folded) + return _folded + + +def _host_folds_case(directory: Path) -> bool: + """Does THIS filesystem match a glob case-insensitively? Measured, not guessed. + + Writes ``aipass_case_probe`` and globs for the UPPERCASE spelling — the same + direction as the defect (an uppercase pattern reaching a lowercase file), not + a generic folding question. The first cut measured the other direction and + came back False even under the emulator, which folds patterns downward only; + a probe that does not travel the defect's direction reports on something + else. A probe + rather than ``sys.platform``, because case-folding is a property of the + FILESYSTEM and not of the OS — macOS is folding by default and + case-sensitive when formatted that way, and a mounted volume can disagree + with its own host. Never skipif: a skip retires the assertion on exactly the + platform whose CI found the defect. + """ + marker = directory / "aipass_case_probe" + marker.write_text("", encoding="utf-8") + try: + return bool(list(directory.glob("AIPASS_CASE_PROBE"))) + finally: + marker.unlink() + + +def test_the_host_probe_is_consistent_with_itself(tmp_path): + """The probe's own control. It must agree with a direct existence check — + otherwise it is reporting a property of glob rather than of the filesystem, + and every branch it gates is chosen on noise.""" + marker = tmp_path / "aipass_case_probe" + marker.write_text("", encoding="utf-8") + by_glob = bool(list(tmp_path.glob("AIPASS_CASE_PROBE"))) + by_stat = (tmp_path / "AIPASS_CASE_PROBE").exists() + marker.unlink() + assert by_glob == by_stat + + +def _decoy(directory: Path, name: str = "wrong_registry.json") -> Path: + """A lowercase counter file that MAPS TO A WRONG IDENTITY. + + Carries a full, well-formed ``branches`` list naming @impostor. That is + deliberate: it makes every assertion below about *who the mail would land as* + rather than about set membership, so a pin cannot pass merely because the + file was filtered out of some list nobody reads. + """ + seat = directory / "impostor" + seat.mkdir(parents=True, exist_ok=True) + path = directory / name + path.write_text( + json.dumps({"branches": [{"name": "IMPOSTOR", "email": "@impostor", "path": str(seat)}]}), + encoding="utf-8", + ) + return path + + +def _real_registry(directory: Path, name: str = "PROJECT_REGISTRY.json") -> Path: + seat = directory / "genuine" + seat.mkdir(parents=True, exist_ok=True) + path = directory / name + path.write_text( + json.dumps({"branches": [{"name": "GENUINE", "email": "@genuine", "path": str(seat)}]}), + encoding="utf-8", + ) + return path + + +class TestTheInstrumentIsHonest: + """Controls. A widening emulator that never widens turns every pin below + green for the wrong reason, and a control that cannot fail is not a control.""" + + def test_positive_control_the_instrument_really_widens_a_listing(self, tmp_path, case_insensitive_fs): + """Exercises THE INSTRUMENT, not a copy of its logic. + + The assertion runs the same ``Path.glob`` production code runs. @aipass + lost a night to a control that re-implemented the matching inline and + reported success while the walk visited zero files. + """ + _decoy(tmp_path) + matched = list(tmp_path.glob("*_REGISTRY.json")) + assert [p.name for p in matched] == ["wrong_registry.json"], ( + "the instrument must make the lowercase decoy visible to an uppercase pattern" + ) + + def test_negative_control_the_instrument_can_say_no(self, tmp_path): + """Same fixture tree, instrument NOT installed. + + The claim is that the EMULATOR is what widens the listing above. On a + case-sensitive host that reads as "the raw glob finds nothing". On + Windows and default macOS the raw glob finds the decoy by itself — the + host folds, which is the entire defect these pins exist for — so the + original spelling of this control failed on the windows-setup leg of + ebb8075d asserting `== []` against a real WindowsPath. + + So the host is PROBED, never assumed and never skipif'd (@memory's + ruling, applied fleet-wide 2026-08-31): write ``Foo``, glob ``foo``. On a + folding host the claim becomes the STRONGER one — the decoy is visible to + the raw glob and ``registries_in`` refuses it anyway, which is what the + production code has to do on the machine that actually folds. Either way + the control can still say no; @spawn's CONTROL_LIVE probe could not until + a mutant caught it lying. + """ + from aipass.ai_mail.apps.handlers.paths import registries_in + + decoy = _decoy(tmp_path) + + if _host_folds_case(tmp_path): + assert decoy in list(tmp_path.glob("*_REGISTRY.json")), ( + "host was probed as case-folding, so the raw glob must see the decoy" + ) + assert registries_in(tmp_path) == [], ( + "on a folding host the reader is the ONLY thing standing between " + "a counter file and the caller — and it must still refuse it" + ) + else: + assert list(tmp_path.glob("*_REGISTRY.json")) == [] + + def test_the_probe_reports_folding_when_the_host_folds(self, tmp_path, case_insensitive_fs): + """The Windows branch of the control above never executes on Linux, so + it would ship unverified. Driven here through the emulator: with a + folding glob installed the probe must SAY so, and the production reader + must still refuse the decoy that the raw glob now hands it. + + This is the assertion that actually ran red on the windows-setup leg — + reproduced on Linux rather than left to the next CI train to discover. + """ + assert _host_folds_case(tmp_path) is True + + from aipass.ai_mail.apps.handlers.paths import registries_in + + decoy = _decoy(tmp_path) + assert decoy in list(tmp_path.glob("*_REGISTRY.json")) + assert registries_in(tmp_path) == [] + + def test_cause_the_host_folds_exactly_as_its_platform_row_says(self, tmp_path): + """CAUSE. What this filesystem does, against the table's expectation. + + Round 4 wrote `is False` here and CI proved that is a Linux fact, not a + universal. A platform whose row is None (macOS, formattable either way) + asserts nothing here — the LINK pin below still holds it to account. + """ + expected = _expected_folding() + if expected is None: + pytest.skip(f"{sys.platform}: filesystem case-folding is configurable — see the LINK pin") + assert _host_folds_case(tmp_path) is expected + + def test_outcome_the_raw_glob_sees_the_decoy_exactly_when_the_row_says_so(self, tmp_path): + """OUTCOME. What that means for the thing under test: whether an + uppercase pattern reaches the lowercase counter file.""" + expected = _expected_folding() + if expected is None: + pytest.skip(f"{sys.platform}: filesystem case-folding is configurable — see the LINK pin") + decoy = _decoy(tmp_path) + assert (decoy in list(tmp_path.glob("*_REGISTRY.json"))) is expected + + def test_link_the_outcome_follows_from_the_cause_on_every_platform(self, tmp_path): + """LINK. Holds with NO platform row at all, which is why macOS can skip + the two above and still be covered: whatever this host does, the glob's + behaviour must follow from the probe's verdict, and the production + reader must refuse the counter EITHER WAY. + + A future red then names its own mechanism — cause pin red means the + table is wrong about the platform, outcome red means glob disagrees with + the probe, link red means the two are no longer connected at all. + """ + folds = _host_folds_case(tmp_path) + decoy = _decoy(tmp_path) + + assert (decoy in list(tmp_path.glob("*_REGISTRY.json"))) is folds + from aipass.ai_mail.apps.handlers.paths import registries_in + + assert registries_in(tmp_path) == [], "the reader refuses the counter on any filesystem" + + def test_the_windows_row_is_driven_here_so_it_cannot_rot(self, tmp_path, case_insensitive_fs): + """The win32 row is derived, so on Linux it would sit unexecuted between + Windows CI runs. @prax's rule: emulate the PLATFORM, not just the denial. + + The round-4 folding emulator already IS a platform emulation, so the + Windows row runs on every Linux run: probe says folding, raw glob sees + the decoy, reader still refuses it — the same three claims the three pins + above make, driven through the row that cannot be measured here. + """ + assert _FOLDS_BY_PLATFORM["win32"] is True + + folds = _host_folds_case(tmp_path) + assert folds is True, "emulated Windows must present as folding" + + decoy = _decoy(tmp_path) + assert decoy in list(tmp_path.glob("*_REGISTRY.json")) + + from aipass.ai_mail.apps.handlers.paths import registries_in + + assert registries_in(tmp_path) == [] + + def test_darwin_is_deliberately_unfixed_not_forgotten(self, tmp_path): + """A None row looks like an omission, so it is pinned as a decision. + + Giving darwin a fixed row survives every other mutant in this file on + Linux — there is no Darwin runner here to contradict it — so without + this pin the table could acquire a false macOS expectation and nothing + would notice until a Mac ran it. macOS is formattable either way AND + folds by default, which is why no fixed answer is honest. + + This is also why the table keys on sys.platform rather than os.name: the + fleet shape says os.name, but that collapses darwin into "posix" and + would assert False on a folding host — the exact species of error this + round exists to fix. + """ + assert _FOLDS_BY_PLATFORM["darwin"] is None + assert "darwin" in _FOLDS_BY_PLATFORM, "an absent key and a None row read the same at runtime" + + def test_the_fixtures_never_write_case_twins(self, tmp_path): + """@memory's round-4 lesson, applied to my own fixtures: a case-twin + decoy cannot COEXIST on a folding filesystem — it OVERWRITES. A probe or + fixture that writes twins measures an overwrite while claiming to + measure case-globbing. + + Mine do not (`wrong_registry.json` vs `PROJECT_REGISTRY.json`, and the + host probe unlinks its marker), and that is pinned rather than trusted, + because it is silent on Linux and destructive on Windows. + """ + decoy = _decoy(tmp_path) + real = _real_registry(tmp_path) + + assert decoy.name.lower() != real.name.lower(), "fixtures would collide on a folding host" + assert decoy.exists() and real.exists() + assert len({decoy.name.lower(), real.name.lower()}) == 2 + + def test_the_decoy_would_genuinely_seat_the_wrong_citizen(self, tmp_path): + """The decoy is a real registry naming a real branch. Without this, a + green pin might only mean 'the file was skipped', not 'the wrong citizen + was never reachable'.""" + decoy = _decoy(tmp_path) + loaded = json.loads(decoy.read_text(encoding="utf-8")) + assert loaded["branches"][0]["email"] == "@impostor" + + +class TestFindProjectRootIgnoresLowercaseCounters: + """``paths.find_project_root`` answers 'which project is this' for the + cross-project delivery fence (delivery.py:379/383). A wrong root there makes + the fence compare two different questions.""" + + def test_a_lowercase_counter_does_not_become_a_project_root(self, tmp_path, case_insensitive_fs): + from aipass.ai_mail.apps.handlers.paths import find_project_root + + project = tmp_path / "project" + _real_registry(project) + nested = project / "branch" / "apps" + nested.mkdir(parents=True) + _decoy(nested.parent) # branch/wrong_registry.json — closer than the real one + + assert find_project_root(nested) == project + + def test_the_real_registry_is_still_found(self, tmp_path, case_insensitive_fs): + """The other half. A filter that refuses everything passes every + did-not-pick-the-decoy assertion in this file.""" + from aipass.ai_mail.apps.handlers.paths import find_project_root + + project = tmp_path / "project" + _real_registry(project) + nested = project / "branch" / "apps" + nested.mkdir(parents=True) + + assert find_project_root(nested) == project + + def test_an_external_project_registry_named_after_itself_survives(self, tmp_path, case_insensitive_fs): + """SUFFIX only, never the stem. External projects name registries after + themselves — Vera-Studio_REGISTRY.json, vera_studio_REGISTRY.json — and a + filter keyed on the stem would delete real citizens to fix a bug.""" + from aipass.ai_mail.apps.handlers.paths import find_project_root + + for stem in ("Vera-Studio", "vera_studio", "feel_good_app"): + project = tmp_path / stem + _real_registry(project, f"{stem}_REGISTRY.json") + nested = project / "src" / "seat" + nested.mkdir(parents=True) + assert find_project_root(nested) == project, f"{stem}_REGISTRY.json must resolve" + + +class TestCallerRegistryNeverResolvesToACounter: + """``branch_detection._find_caller_registry`` picks the registry that NAMES + the caller. It returns the FIRST non-AIPass registry it meets walking up, so + a decoy does not merely add a candidate — it ends the walk.""" + + def test_the_impostor_registry_is_never_returned(self, tmp_path, monkeypatch, case_insensitive_fs): + from aipass.ai_mail.apps.handlers.users import branch_detection as bd + + project = tmp_path / "project" + real = _real_registry(project) + caller = project / "src" / "seat" + caller.mkdir(parents=True) + _decoy(caller) + + monkeypatch.setenv("AIPASS_CALLER_CWD", str(caller)) + monkeypatch.setattr(bd, "BRANCH_REGISTRY_PATH", tmp_path / "AIPASS_REGISTRY.json") + + assert bd._find_caller_registry() == real + + def test_a_caller_identity_lookup_does_not_seat_the_impostor(self, tmp_path, monkeypatch, case_insensitive_fs): + """The assertion that is about WHO, not about which file. Both registries + are well-formed and both name a branch; only one of them is real.""" + from aipass.ai_mail.apps.handlers.users import branch_detection as bd + + project = tmp_path / "project" + _real_registry(project) + caller = project / "src" / "seat" + caller.mkdir(parents=True) + _decoy(caller) + + monkeypatch.setenv("AIPASS_CALLER_CWD", str(caller)) + monkeypatch.setattr(bd, "BRANCH_REGISTRY_PATH", tmp_path / "AIPASS_REGISTRY.json") + + assert bd._lookup_branch_by_name("impostor") is None + assert bd._lookup_branch_by_name("genuine") is not None + + +class TestReplyPathValidationIsNotSatisfiedByACounter: + """``reply._validate_reply_path`` guards delivery TOWARD an external project. + It is existence-only, so a decoy anywhere up the chain is a full pass — this + is the outbound direction of the same defect.""" + + def test_a_counter_ancestor_does_not_validate_a_reply_path(self, tmp_path, case_insensitive_fs): + from aipass.ai_mail.apps.handlers.email.reply import _validate_reply_path + + stray = tmp_path / "not-a-project" + _decoy(stray) + inbox = stray / "seat" / ".ai_mail.local" / "inbox.json" + inbox.parent.mkdir(parents=True) + inbox.write_text("{}", encoding="utf-8") + + allowed, reason = _validate_reply_path(str(inbox)) + assert allowed is False + assert "REGISTRY" in reason + + def test_a_real_registry_ancestor_still_validates(self, tmp_path, case_insensitive_fs): + from aipass.ai_mail.apps.handlers.email.reply import _validate_reply_path + + project = tmp_path / "project" + _real_registry(project) + inbox = project / "seat" / ".ai_mail.local" / "inbox.json" + inbox.parent.mkdir(parents=True) + inbox.write_text("{}", encoding="utf-8") + + allowed, reason = _validate_reply_path(str(inbox)) + assert allowed is True, reason + + +class TestResidentAndProjectTreeDiscovery: + """The two sites @drone's sweep did not list. Both are one or two levels + down rather than a walk up, and both decide WHICH CITIZENS EXIST — the + resident roster and the verified-admin cross-project bridge.""" + + def test_resident_discovery_skips_lowercase_counters(self, tmp_path, case_insensitive_fs): + from aipass.ai_mail.apps.handlers.registry.read import resident_registry_paths + + projects = tmp_path / "projects" + real = _real_registry(projects / "Genuine") + _decoy(projects / "Counterfeit") + + assert resident_registry_paths(tmp_path) == [real] + + def test_project_tree_bridge_never_admits_the_impostor(self, tmp_path, case_insensitive_fs): + from aipass.ai_mail.apps.handlers.registry.read import get_project_tree_branches + + projects = tmp_path / "projects" + _real_registry(projects / "Genuine") + _decoy(projects / "Counterfeit") + + found = get_project_tree_branches(tmp_path) + assert "@impostor" not in found, "the admin bridge must not gain a citizen from a counter file" + assert "@genuine" in found + + def test_caller_project_branches_skips_the_counter(self, tmp_path, case_insensitive_fs): + from aipass.ai_mail.apps.handlers.registry.read import get_caller_project_branches + + project = tmp_path / "project" + _real_registry(project) + caller = project / "src" / "seat" + caller.mkdir(parents=True) + _decoy(caller) + + found = get_caller_project_branches(str(caller)) + assert "@impostor" not in found + assert "@genuine" in found + + +class TestNoInlineRegistryGlobSurvivesInTheTree: + """The drift guard. Six sites were fixed; the seventh is the one written next + month by someone who never read this file, so the ban is structural rather + than remembered.""" + + HANDLERS = Path(__file__).resolve().parents[1] / "apps" + READER_FILE = "paths.py" + + @staticmethod + def _mentions_registry(arg, module_constants): + """Whether this glob argument carries a registry pattern. + + TWO spellings, because only catching the first would have let my own fix + through: ``resident_registry_paths`` globbed + ``projects.glob(RESIDENT_REGISTRY_GLOB)``, a NAME, and a literal-only ban + reported that site clean while it held the defect. A rule blind to the + shape the code actually uses is worse than no rule, because it reports a + swept tree. + """ + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + return "registry" in arg.value.lower() + if isinstance(arg, ast.Name): + return "registry" in arg.id.lower() or "registry" in str(module_constants.get(arg.id, "")).lower() + return False + + def _registry_glob_calls(self): + """Every ``.glob(...)``/``.rglob(...)`` reaching for a registry pattern, + outside the one filtered reader.""" + offenders = [] + for source in self.HANDLERS.rglob("*.py"): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + constants = { + target.id: node.value.value + for node in tree.body + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant) + for target in node.targets + if isinstance(target, ast.Name) + } + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + if node.func.attr not in ("glob", "rglob"): + continue + for arg in node.args: + if self._mentions_registry(arg, constants): + offenders.append((source.name, node.lineno, ast.unparse(arg))) + return offenders + + def test_the_ban_is_measured_not_asserted(self): + """Positive control for the AST walk itself. If the parser visits nothing + — wrong root, renamed package — the ban below passes vacuously and + reports a clean tree it never read.""" + seen = [s.name for s in self.HANDLERS.rglob("*.py")] + assert len(seen) > 20, f"the walk must actually reach the handler tree, saw {len(seen)}" + assert self.READER_FILE in seen + + def test_the_ban_catches_a_named_constant_not_only_a_literal(self): + """Negative control for the RULE. Feeds the matcher the exact shape my + own resident-discovery site used — a bare Name — and requires a + conviction. Without this, tightening the ban to literals-only would pass + every test in this class while reopening the hole.""" + module_constants = {"RESIDENT_REGISTRY_GLOB": "*/*_REGISTRY.json"} + by_name = ast.parse("d.glob(RESIDENT_REGISTRY_GLOB)").body[0].value.args[0] + by_literal = ast.parse('d.glob("*_REGISTRY.json")').body[0].value.args[0] + innocent = ast.parse('d.glob("*.json")').body[0].value.args[0] + + assert self._mentions_registry(by_name, module_constants) is True + assert self._mentions_registry(by_literal, {}) is True + assert self._mentions_registry(innocent, {}) is False + + def test_only_the_shared_reader_globs_for_registries(self): + offenders = [o for o in self._registry_glob_calls() if o[0] != self.READER_FILE] + assert offenders == [], ( + "registry globs must go through paths.registries_in(), which re-checks the " + f"name case-sensitively. Inline globs found: {offenders}" + ) diff --git a/src/aipass/ai_mail/tests/test_wake.py b/src/aipass/ai_mail/tests/test_wake.py index f1000cab0..5eaa2c792 100644 --- a/src/aipass/ai_mail/tests/test_wake.py +++ b/src/aipass/ai_mail/tests/test_wake.py @@ -1071,7 +1071,11 @@ class _Done: assert any(s[0] == "ok" and s[1] == "manager" for s in status.steps) # ...and the spawn is an interactive tmux session, not the headless monitor. assert any(s[0] == "ok" and s[1] == "spawn" and "tmux" in s[2] for s in status.steps) - assert [c[:2] for c in tmux_calls] == [["tmux", "new-session"], ["tmux", "send-keys"]] + assert [c[:2] for c in tmux_calls] == [ + ["tmux", "new-session"], + ["tmux", "set-option"], + ["tmux", "send-keys"], + ] # Prompt travels via file (quoting-proof, debuggable). prompt_file = branch_path / ".daemon" / "last_wake_prompt.txt" assert prompt_file.read_text(encoding="utf-8") == "continue the work" @@ -1504,7 +1508,11 @@ def test_daemon_manager_wake_is_interactive_tmux_with_no_pin(self, tmp_path, mon status, ok = wake_branch("@testbranch", sender="@daemon") assert ok is True - assert [c[:2] for c in calls["tmux"]] == [["tmux", "new-session"], ["tmux", "send-keys"]] + assert [c[:2] for c in calls["tmux"]] == [ + ["tmux", "new-session"], + ["tmux", "set-option"], + ["tmux", "send-keys"], + ] assert calls["popen"] == [], "interactive manager wake must not reach the monitor pipeline" # No monitor process means no pin site at all. assert not any(str(wake_mod.MONITOR_SCRIPT) in " ".join(c) for c in calls["tmux"]) @@ -1588,7 +1596,11 @@ def test_scheduled_false_keeps_manager_wake_interactive(self, tmp_path, monkeypa status, ok = wake_branch("@testbranch", sender="@daemon", scheduled=False) assert ok is True - assert [c[:2] for c in calls["tmux"]] == [["tmux", "new-session"], ["tmux", "send-keys"]] + assert [c[:2] for c in calls["tmux"]] == [ + ["tmux", "new-session"], + ["tmux", "set-option"], + ["tmux", "send-keys"], + ] assert calls["popen"] == [] def test_scheduled_non_manager_spawn_is_byte_identical(self, tmp_path, monkeypatch): @@ -1886,3 +1898,373 @@ def test_manager_gate_log_does_not_claim_mail_it_did_not_send(self): as long as it did (@devpulse P0, 2026-08-21).""" src = _Path(wake_mod.__file__).read_text(encoding="utf-8") assert "wake skipped, mail delivered" not in src + + +# --- wake-lane rulings, Patrick 2026-08-30 ------------------------------ + + +class TestWakeModelPolicy: + """resolve_wake_model() — "managers are fable thats it, only manager run fable". + + Two halves tested separately because they fail in opposite directions: the + manager half over-applies Fable if it is wrong, the non-manager half lets it + leak. A test suite that only pinned the first would pass a policy that gave + Fable to everybody. + """ + + def test_manager_with_no_requested_model_gets_fable(self): + assert wake_mod.resolve_wake_model("manager", None) == "fable" + + def test_manager_request_is_overridden_not_honoured(self): + """A schedule naming a model is a preference; the ruling is a policy.""" + assert wake_mod.resolve_wake_model("manager", "sonnet") == "fable" + + def test_manager_already_asking_for_fable_still_gets_fable(self): + assert wake_mod.resolve_wake_model("manager", "claude-fable-5") == "fable" + + def test_non_manager_with_no_request_keeps_todays_default(self): + """The ruling changed who gets Fable, not what everyone else defaults to.""" + assert wake_mod.resolve_wake_model("aipass_framework", None) == DEFAULT_MODEL + + def test_non_manager_request_is_honoured_as_today(self): + assert wake_mod.resolve_wake_model("aipass_framework", "sonnet") == "sonnet" + + def test_non_manager_asking_for_fable_falls_back_never_refuses(self): + """Refusing the wake would punish the target for its schedule's model + field. The wake happens; the model does not.""" + assert wake_mod.resolve_wake_model("aipass_framework", "fable") == DEFAULT_MODEL + + def test_non_manager_full_fable_id_is_refused_too(self): + """The spelling that walks past an equality check. `--model` takes both + `fable` and `claude-fable-5`, so a policy comparing to the bare alias + would hand a non-manager the exact model the ruling forbids.""" + assert wake_mod.resolve_wake_model("aipass_framework", "claude-fable-5") == DEFAULT_MODEL + + def test_non_manager_fable_is_refused_whatever_the_casing(self): + assert wake_mod.resolve_wake_model("specialist", "FABLE") == DEFAULT_MODEL + + def test_unclassified_citizen_is_treated_as_non_manager(self): + """An unreadable passport reaches this function as "". Failing toward + manager would silently move an ordinary branch onto Fable — the same + direction is_manager() already refuses to fail in.""" + assert wake_mod.resolve_wake_model("", None) == DEFAULT_MODEL + assert wake_mod.resolve_wake_model("", "fable") == DEFAULT_MODEL + + def test_fable_is_a_known_alias(self): + """The lane names a model the CLI actually resolves.""" + assert "fable" in KNOWN_MODEL_ALIASES + + +def _tmux_line(calls, verb): + """The single tmux invocation whose second word is `verb`, as one string.""" + matches = [c for c in calls["tmux"] if len(c) > 1 and c[1] == verb] + assert len(matches) == 1, f"expected exactly one 'tmux {verb}', got {matches}" + return " ".join(matches[0]) + + +class TestUnattendedWakesBypassPermissions: + """Ruling 1, Patrick 2026-08-30: "always bypass permissions always, claude + alone will nvr work." + + @vera's first external wake launched as a bare `claude`, sat in default + permission mode, and had Bash DENIED mid-playbook with nobody present to + approve it. The headless lane has carried the flag for months; the + interactive manager lane was the one still spawning plain claude. + """ + + def test_interactive_manager_lane_carries_the_bypass_flag(self, tmp_path, monkeypatch): + _make_scheduled_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch) + calls = _record_spawn_routes(monkeypatch) + + status, ok = wake_branch("@testbranch", sender="@daemon") + + assert ok is True + assert "--permission-mode bypassPermissions" in _tmux_line(calls, "send-keys") + + def test_interactive_manager_lane_names_its_model_never_a_bare_claude(self, tmp_path, monkeypatch): + """Naming nothing is how @vera reached Fable by accident — right answer, + no decision behind it. The lane states the model it means.""" + _make_scheduled_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch) + calls = _record_spawn_routes(monkeypatch) + + status, ok = wake_branch("@testbranch", sender="@daemon") + + assert ok is True + assert "--model fable" in _tmux_line(calls, "send-keys") + + def test_headless_lane_still_bypasses(self, tmp_path, monkeypatch): + """Regression guard: the lane that already complied must keep complying.""" + _make_scheduled_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch) + calls = _record_spawn_routes(monkeypatch) + + status, ok = wake_branch("@testbranch", sender="@daemon", scheduled=True) + + assert ok is True + cmd = calls["popen"][0]["cmd"] + assert "--permission-mode" in cmd + assert cmd[cmd.index("--permission-mode") + 1] == "bypassPermissions" + + +class TestDaemonSessionMarking: + """Ruling 3, Patrick 2026-08-30: a daemon-started session must be + recognizably daemon work, or a human kills it as a leftover. + + He killed @vera's live session mid-run — `daemon-vera-192848` read as his + own stale tmux. Two markings, and only the first is guaranteed: tmux either + creates the session under the loud name or new-session already failed. + """ + + def test_session_name_is_unmistakably_a_daemon_wake(self, tmp_path, monkeypatch): + _make_scheduled_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch) + calls = _record_spawn_routes(monkeypatch) + + status, ok = wake_branch("@testbranch", sender="@daemon") + + assert ok is True + new_session = [c for c in calls["tmux"] if c[1] == "new-session"][0] + session = new_session[new_session.index("-s") + 1] + assert session.startswith(wake_mod.DAEMON_SESSION_PREFIX) + + def test_the_session_name_that_got_killed_cannot_come_back(self, tmp_path, monkeypatch): + """`daemon-vera-192848` is not a hypothesis — Patrick read that exact + shape as his own leftover tmux and killed it mid-playbook. + + The pin above reads DAEMON_SESSION_PREFIX, so it passes for ANY value + the constant holds, including the one that already failed. This one + names the failed string instead, and asks the name to say out loud that + a machine started the session. + """ + _make_scheduled_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch) + calls = _record_spawn_routes(monkeypatch) + + status, ok = wake_branch("@testbranch", sender="@daemon") + + assert ok is True + new_session = [c for c in calls["tmux"] if c[1] == "new-session"][0] + session = new_session[new_session.index("-s") + 1] + assert not session.startswith("daemon-"), "this is the name a human already killed" + assert "AIPASS" in session, "the name must say a machine started this, not a person" + + def test_marking_lands_before_the_agent_starts(self, tmp_path, monkeypatch): + """A window is killable from the moment it exists. Marking after + send-keys leaves exactly the gap this closes.""" + _make_scheduled_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch) + calls = _record_spawn_routes(monkeypatch) + + status, ok = wake_branch("@testbranch", sender="@daemon") + + assert ok is True + verbs = [c[1] for c in calls["tmux"]] + assert verbs == ["new-session", "set-option", "send-keys"] + + def test_the_marker_is_queryable_and_names_the_branch(self, tmp_path, monkeypatch): + """A user option any tool can read back, rather than string-matching a + session-name prefix.""" + _make_scheduled_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch) + calls = _record_spawn_routes(monkeypatch) + + status, ok = wake_branch("@testbranch", sender="@daemon") + + assert ok is True + option = [c for c in calls["tmux"] if c[1] == "set-option"][0] + assert "@aipass_daemon_wake" in option + payload = json.loads(option[-1]) + assert payload["branch"] == "@testbranch" + assert payload["sender"] == "@daemon" + + def test_a_marking_failure_warns_and_still_wakes(self, tmp_path, monkeypatch): + """The session is already alive when the option is set. Refusing to + start the agent over a cosmetic label trades real work for a tag — + but the thin marking is said out loud, never swallowed.""" + _make_scheduled_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch) + calls = _record_spawn_routes(monkeypatch) + + real_run = wake_mod.subprocess.run + + def _run(cmd, **kwargs): + if len(cmd) > 1 and cmd[1] == "set-option": + raise subprocess.CalledProcessError(1, cmd, stderr="no server running") + return real_run(cmd, **kwargs) + + monkeypatch.setattr(wake_mod.subprocess, "run", _run) + + status, ok = wake_branch("@testbranch", sender="@daemon") + + assert ok is True, "a failed label must not cancel the wake" + assert "send-keys" in [c[1] for c in calls["tmux"]] + mark = status.find_step("mark") + assert mark is not None and mark[0] == "warn" + + def test_the_interactive_lane_is_not_written_to_the_dispatch_register(self, tmp_path, monkeypatch): + """Deliberate, and the reason is the register's own contract: + open_dispatch() takes expected_seconds from the lane's timeout, and this + lane has no monitor and no timeout. Every entry would stay outstanding + forever and go overdue against an invented number — a crash detector + full of false alarms. Marking a window and tracking a promised dispatch + are two questions; only one of them has a monitor to close it.""" + _make_scheduled_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch) + _record_spawn_routes(monkeypatch) + + from aipass.ai_mail.apps.handlers.dispatch import register as register_mod + + opened = [] + monkeypatch.setattr(register_mod, "open_dispatch", lambda **kw: opened.append(kw) or "id", raising=True) + + status, ok = wake_branch("@testbranch", sender="@daemon") + + assert ok is True + assert opened == [], "the interactive lane must not mint a dispatch nothing can close" + + +class TestModelPolicyReachesBothSpawnLanes: + """The policy is decided once, above the fork. These pin that BOTH lanes + read that one decision — a rule enforced in only one lane is the shape the + fleet keeps paying for.""" + + def test_manager_schedule_asking_for_sonnet_still_spawns_fable(self, tmp_path, monkeypatch): + _make_scheduled_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch) + calls = _record_spawn_routes(monkeypatch) + + status, ok = wake_branch("@testbranch", sender="@daemon", model="sonnet") + + assert ok is True + assert "--model fable" in _tmux_line(calls, "send-keys") + + def test_headless_manager_wake_is_fable_too(self, tmp_path, monkeypatch): + """A manager on the scheduled lane never touches tmux, and the ruling + does not stop at the tmux door.""" + _make_scheduled_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch) + calls = _record_spawn_routes(monkeypatch) + + status, ok = wake_branch("@testbranch", sender="@daemon", scheduled=True, model="opus") + + assert ok is True + cmd = calls["popen"][0]["cmd"] + assert cmd[cmd.index("--model") + 1] == "fable" + + def test_non_manager_asking_for_fable_spawns_on_the_default(self, tmp_path, monkeypatch): + _make_scheduled_fixtures(tmp_path, monkeypatch, citizen_class="specialist") + _patch_wake_deps(monkeypatch) + calls = _record_spawn_routes(monkeypatch) + + status, ok = wake_branch("@testbranch", model="claude-fable-5") + + assert ok is True + cmd = calls["popen"][0]["cmd"] + assert cmd[cmd.index("--model") + 1] == DEFAULT_MODEL + + def test_the_model_decision_is_a_named_step(self, tmp_path, monkeypatch): + """Which model ran, and on what classification, readable from the status + without grepping a log.""" + _make_scheduled_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch) + _record_spawn_routes(monkeypatch) + + status, ok = wake_branch("@testbranch", sender="@daemon", model="sonnet") + + step = status.find_step("model") + assert step is not None + assert "fable" in step[2] and "manager" in step[2] + + def test_a_branch_with_no_passport_never_lands_on_fable(self, tmp_path, monkeypatch): + """The hoisted default, end to end. citizen_class is bound before the + passport read, and it is bound to "" — an unreadable passport must not + be able to promote a branch onto the managers-only model.""" + _make_wake_fixtures(tmp_path, monkeypatch) # no .trinity/passport.json + _patch_wake_deps(monkeypatch) + calls = _record_spawn_routes(monkeypatch) + + status, ok = wake_branch("@testbranch") + + assert ok is True + cmd = calls["popen"][0]["cmd"] + assert cmd[cmd.index("--model") + 1] == DEFAULT_MODEL + assert calls["tmux"] == [] + + +class TestDispatchStatusLabelsAreACrossBranchContract: + """@daemon's scheduler classifies BLOCKED-vs-FAILED by these four step + LABELS plus a fail status — never by matching ``status.summary``, because + summary is human prose I am free to reword (their S53, 2026-08-30). + + That makes the labels a contract between two branches, and the contract is + pinned HERE rather than in their tree: the rename would be written in this + file, so this is where it has to go red. If one of these disappears, their + scheduler silently reclassifies "never started" as "ran and failed", stamps + last_run, and starts eating scheduling periods again — the exact defect they + fixed. Renaming one is allowed; doing it without telling @daemon is not. + """ + + CONTRACT = ("pause", "lock", "blocked", "lock-acquire") + + def test_every_contracted_label_still_exists_in_the_wake_lane(self): + """Cheap and total: the four strings @daemon reads must still be emitted + somewhere in wake.py. A grep, deliberately — it survives refactors of + WHICH path emits a label, and only fails when a label truly leaves.""" + src = _Path(wake_mod.__file__).read_text(encoding="utf-8") + missing = [label for label in self.CONTRACT if f'"{label}"' not in src] + assert missing == [], ( + f"@daemon's scheduler reads these step labels: {missing} no longer exist. " + "Tell them before moving one — their block-vs-fail classification depends on it." + ) + + def test_pause_is_a_named_fail_step(self, tmp_path, monkeypatch): + _make_wake_fixtures(tmp_path, monkeypatch) + pause = tmp_path / ".aipass" / "autonomous_pause" + pause.parent.mkdir(parents=True, exist_ok=True) + pause.touch() + + status, ok = wake_branch("@testbranch", auto=True) + + assert ok is False + assert status.find_step("pause")[0] == "fail" + + def test_an_active_lock_is_a_named_fail_step_under_auto(self, tmp_path, monkeypatch): + _make_wake_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch, _check_lock=lambda p: {"pid": 999, "timestamp": "now"}) + + status, ok = wake_branch("@testbranch", auto=True) + + assert ok is False + assert status.find_step("lock")[0] == "fail" + + def test_occupancy_is_the_blocked_step(self, tmp_path, monkeypatch): + _make_wake_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch, _is_branch_occupied=lambda p: True) + + status, ok = wake_branch("@testbranch", auto=True) + + assert ok is False + assert status.find_step("blocked")[0] == "fail" + + def test_a_refused_lock_acquire_is_its_own_named_step(self, tmp_path, monkeypatch): + _make_wake_fixtures(tmp_path, monkeypatch) + _patch_wake_deps(monkeypatch, _acquire_lock=lambda p, pid: (False, "held by 4242")) + monkeypatch.setattr("subprocess.Popen", lambda *a, **kw: _FakeProc()) + + status, ok = wake_branch("@testbranch", auto=True) + + assert status.find_step("lock-acquire")[0] == "fail" + + def test_resolve_and_blocklist_stay_failures_on_purpose(self, tmp_path, monkeypatch): + """The other half of their rule, pinned so it is not "fixed" into + consistency later: neither is transient, so nothing about waiting two + more minutes changes the answer. They must NOT join the blocked set.""" + _make_wake_fixtures(tmp_path, monkeypatch) + + status, ok = wake_branch("@nonexistent") + + assert ok is False + assert status.find_step("resolve")[0] == "fail" + assert status.find_step("blocked") is None, "a missing branch is not a transient block" diff --git a/src/aipass/aipass/apps/handlers/__init__.py b/src/aipass/aipass/apps/handlers/__init__.py index d9172c255..544178715 100644 --- a/src/aipass/aipass/apps/handlers/__init__.py +++ b/src/aipass/aipass/apps/handlers/__init__.py @@ -1,6 +1,7 @@ """AIPASS handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "aipass.aipass" @@ -11,24 +12,55 @@ def _find_real_caller(): Skips this file, importlib internals, and frozen modules. Returns tuple: (file_path, import_line) or (None, None). - """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) - - for frame_info in stack: - filename = frame_info.filename - if this_file in str(Path(filename).resolve()): + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on the + Windows CI gate 2026-08-31: inspect.stack() needs a READABLE CWD, and it + needs one before any of this function's own code runs. It builds a FrameInfo + per frame, which calls getsourcefile() -> getmodule() -> os.path.realpath(); + ntpath.realpath calls os.getcwd() unconditionally on its first lines, before + it even checks whether the path is absolute, and that call site in getmodule + is not inside a try. On POSIX the equivalent raise happens earlier, inside + getabsfile(), where inspect catches it — which is why this was invisible on + Linux for as long as it existed. A frame's co_filename is already a string + in memory; reading it touches nothing. + """ + # Path.resolve() reaches the same ntpath.realpath, so this is guarded too — + # __file__ is already absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back continue - if filename.startswith("<") or "importlib" in filename: + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename + + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + # linecache is what inspect used for code_context; called directly it + # reads one named file and returns "" rather than raising when it cannot. + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -52,10 +84,11 @@ def _guard_branch_access(): caller_file, import_line = _find_real_caller() if caller_file is None: - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return + # No caller outside this file: an interactive session, a -c script, or an + # importlib-only stack. All three are allowed. This used to walk + # inspect.stack() again looking for / and then return + # either way — a second copy of the cwd dependency above, in service of a + # branch that could not change the answer. return branch_path = "/" + MY_BRANCH.replace(".", "/") + "/" diff --git a/src/aipass/aipass/apps/handlers/admin_lane.py b/src/aipass/aipass/apps/handlers/admin_lane.py index f2a769e87..3c5815602 100644 --- a/src/aipass/aipass/apps/handlers/admin_lane.py +++ b/src/aipass/aipass/apps/handlers/admin_lane.py @@ -69,7 +69,7 @@ def _registry_path() -> Path | None: """Locate the root ``*_REGISTRY.json``, or None.""" branch_root = Path(__file__).resolve().parents[2] result = _discover_registry(package_root=str(branch_root)) - return result if result.exists() else None + return result if result is not None and result.exists() else None def _read_json(path: Path) -> dict | None: diff --git a/src/aipass/aipass/apps/handlers/cross_os/run_record.py b/src/aipass/aipass/apps/handlers/cross_os/run_record.py index ceb8b4922..b0f8965e2 100644 --- a/src/aipass/aipass/apps/handlers/cross_os/run_record.py +++ b/src/aipass/aipass/apps/handlers/cross_os/run_record.py @@ -79,7 +79,7 @@ def _resolve_aipass_home() -> str: if home: return home registry = find_registry(package_root=str(Path(__file__).resolve().parent)) - if registry.exists(): + if registry is not None and registry.exists(): return str(registry.parent) return "" diff --git a/src/aipass/aipass/apps/handlers/init/bootstrap.py b/src/aipass/aipass/apps/handlers/init/bootstrap.py index e425608fc..09407150b 100644 --- a/src/aipass/aipass/apps/handlers/init/bootstrap.py +++ b/src/aipass/aipass/apps/handlers/init/bootstrap.py @@ -48,6 +48,7 @@ is_projects_child, is_throwaway_path, ) +from aipass.aipass.shared.registry_discovery import registries_in logger = logging.getLogger(__name__) @@ -454,7 +455,7 @@ def update_project(target: Path) -> dict: # Locate the project registry to confirm this is an AIPass project and # derive the project name without parsing JSON (filename encodes the name). - registry_files = list(target.glob("*_REGISTRY.json")) + registry_files = registries_in(target) if not registry_files: raise ValueError("No AIPass project found — run 'aipass init' first") registry_path = registry_files[0] diff --git a/src/aipass/aipass/apps/handlers/init/git_auth.py b/src/aipass/aipass/apps/handlers/init/git_auth.py index 84c897a1c..de70a366f 100644 --- a/src/aipass/aipass/apps/handlers/init/git_auth.py +++ b/src/aipass/aipass/apps/handlers/init/git_auth.py @@ -56,6 +56,7 @@ from aipass.prax import logger from aipass.aipass.apps.handlers.json import json_handler +from aipass.aipass.shared.registry_discovery import registries_in MANAGER_CLASS = "manager" @@ -124,7 +125,7 @@ def find_registry(target: Path) -> Optional[Path]: """ current = target.resolve() for candidate in [current, *current.parents]: - matches = sorted(candidate.glob("*_REGISTRY.json")) + matches = registries_in(candidate) if matches: return matches[0] return None diff --git a/src/aipass/aipass/apps/handlers/json/json_handler.py b/src/aipass/aipass/apps/handlers/json/json_handler.py index f73bc1238..68d6fd8b5 100644 --- a/src/aipass/aipass/apps/handlers/json/json_handler.py +++ b/src/aipass/aipass/apps/handlers/json/json_handler.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: json_handler.py # Description: Branch-local shim — delegates to aipass.aipass.shared.json_handler -# Version: 2.0.0 +# Version: 2.1.0 # Created: 2026-04-16 -# Modified: 2026-06-06 +# Modified: 2026-08-31 # ============================================= """Branch-local JSON handler — thin shim over the shared ``aipass.aipass.shared`` library. @@ -16,25 +16,53 @@ from __future__ import annotations -import inspect +import sys from pathlib import Path from typing import Any, Dict, Optional +from aipass.aipass.apps.handlers.module_root import module_file from aipass.aipass.shared.json_handler import JsonHandler def _get_caller_module_name() -> str: - """Auto-detect calling module name from call stack.""" - stack = inspect.stack() - if len(stack) > 2: - caller_path = Path(stack[2].filename) - module_name = caller_path.stem - if module_name and not module_name.startswith("_"): - return module_name + """Auto-detect calling module name from call stack. + + Walks the frame chain with ``sys._getframe`` rather than + ``inspect.stack()``. MEASURED 2026-08-31 (@canary's exposure report, + @devpulse's round-4 follow-up): ``inspect.stack()`` builds a FrameInfo per + frame, and for any frame whose filename is not on disk — ```` from + a ``-c`` command-line source, a ``compile()``d source, the frozen + importlib frames — + ``getsourcefile()`` falls through to ``getmodule()``, whose module-scanning + loop calls ``os.path.realpath`` OUTSIDE the ``try`` that wraps + ``getabsfile``. ``ntpath.realpath`` then reads ``os.getcwd()`` + unconditionally. + + On this hot path that made LOGGING TAKE DOWN THE CALLER IT LOGS FOR: + ``log_operation`` resolves the module name BEFORE its own ``try``, so the + raise escaped every handler below it. A frame's ``co_filename`` is already + a string in memory; reading it touches no filesystem. + + Returns: + The calling module's filename stem, or ``"unknown"`` when the stack is + too shallow or the name is private. Behaviour is unchanged from the + ``inspect.stack()`` form — only the mechanism moved. + """ + try: + frame = sys._getframe(2) + except ValueError: + # Stack shallower than the caller-of-log_operation depth; the + # inspect.stack() form spelled this as `len(stack) > 2`. + return "unknown" + module_name = Path(frame.f_code.co_filename).stem + if module_name and not module_name.startswith("_"): + return module_name return "unknown" -_PKG_ROOT = Path(__file__).resolve().parents[4] +# module_file, not resolve(): this line runs at IMPORT, and on Windows +# resolve() reads the working directory (see handlers/module_root.py). +_PKG_ROOT = module_file(__file__).parents[4] AIPASS_BRANCH_ROOT = _PKG_ROOT / "aipass" AIPASS_JSON_DIR = AIPASS_BRANCH_ROOT / "aipass_json" diff --git a/src/aipass/aipass/apps/handlers/module_root.py b/src/aipass/aipass/apps/handlers/module_root.py new file mode 100644 index 000000000..c4eaccbb3 --- /dev/null +++ b/src/aipass/aipass/apps/handlers/module_root.py @@ -0,0 +1,93 @@ +# =================== AIPass ==================== +# Name: module_root.py +# Description: Resolve a module's __file__ without an import-time cwd read +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""One guarded spelling for module-level ``__file__`` resolution. + +``ntpath.realpath`` computes ``os.getcwd()`` UNCONDITIONALLY — on its first +lines, before it checks whether the path is even absolute, unlike +``posixpath`` — and ``Path.resolve()`` routes through it. So on Windows every +``Path(__file__).resolve()`` REACHED AT IMPORT is an import-time +working-directory dependency: a process whose cwd was deleted, or whose cwd is +a disconnected network share, cannot import the module at all. Measured on the +Windows CI gate 2026-08-31 (@memory's finding, @devpulse's fleet dispatch). + +The discriminator is *reached at import*, not *written at module scope*: a call +inside a function counts whenever import-time code calls that function. This +branch carried five such sites; they route through :func:`module_file` instead +of each growing its own ``try``/``except``. + +Shape mirrors @memory's ``repo_root.module_file`` — the ratified fleet cure — +sized to this branch: aipass resolves no repo root at import time (readme_map's +walk and ping_sweep's registry walk are both call-time), so only the resolve +guard lives here. + +IMPORTING THIS MODULE MUST NEVER RAISE. Its callers resolve at module level, +so anything this file does at import time happens during THEIR import — which +is why the audit line on the fallback path is written defensively. The +``json_handler`` import is function-local for the same reason: ``json_handler`` +is itself a caller, so a module-level edge back would be a cycle. +""" + +from __future__ import annotations + +from pathlib import Path + +from aipass.prax import logger + +MODULE_NAME = "module_root" + + +def module_file(file: str) -> Path: + """A module's own path, absolute, resolved when the filesystem allows. + + ``.resolve()`` is still attempted, because normalising symlinks is why the + call is there and it succeeds on every healthy machine. The fallback is + only reached in the world where the alternative is a dead import, and it is + sound: ``__file__`` has been absolute since Python 3.9, so the return is the + right file either way — just spelled through the symlink rather than past + it. + + Args: + file: A module's ``__file__``. + + Returns: + The module's path, resolved if the filesystem could be asked. + """ + path = Path(file) + try: + return path.resolve() + except OSError as exc: + # debug, not warning: in the world that reaches this line EVERY module + # import takes it, and five identical warnings describe one condition. + # The condition is reported once, loudly, by whichever lane fails on it. + logger.debug("[%s] cannot resolve %s (%s) — using its absolute spelling", MODULE_NAME, path, type(exc).__name__) + _record_unresolved(path, exc) + return path + + +def _record_unresolved(path: Path, exc: OSError) -> None: + """Record the fallback without ever raising. + + Runs at module import time on every caller; a diagnostic write that fails + in a bare world must not become the import crash this module exists to + prevent. + + Args: + path: The ``__file__`` that could not be resolved. + exc: The ``OSError`` ``resolve()`` raised. + """ + try: + from aipass.aipass.apps.handlers.json import json_handler + + json_handler.log_operation( + "module_file_unresolved", + {"path": str(path), "error": f"{type(exc).__name__}: {exc}"}, + module_name=MODULE_NAME, + ) + except Exception as inner: # noqa: BLE001 - an audit line must never take an import down + logger.debug("[%s] fallback not recorded: %s: %s", MODULE_NAME, type(inner).__name__, inner) diff --git a/src/aipass/aipass/apps/handlers/ping_sweep/__init__.py b/src/aipass/aipass/apps/handlers/ping_sweep/__init__.py index 208e7ad89..4c8256685 100644 --- a/src/aipass/aipass/apps/handlers/ping_sweep/__init__.py +++ b/src/aipass/aipass/apps/handlers/ping_sweep/__init__.py @@ -26,6 +26,8 @@ from aipass.prax import logger from aipass.aipass.apps.handlers.json import json_handler +from aipass.aipass.apps.handlers.module_root import module_file +from aipass.aipass.shared.registry_discovery import registries_in TEST_TOKEN = "[AIPASS-TEST — do not update memories, do not execute, reply 'ack' only]" TIMEOUT_PER_BRANCH = 30 @@ -44,7 +46,8 @@ "devpulse", ] -_BRANCH_ROOT = Path(__file__).resolve().parents[3] +# module_file, not resolve(): import-time cwd read on Windows (module_root). +_BRANCH_ROOT = module_file(__file__).parents[3] def _discover_branches() -> list[str]: @@ -52,7 +55,7 @@ def _discover_branches() -> list[str]: cwd = Path.cwd() search = cwd for _ in range(10): - candidates = list(search.glob("*_REGISTRY.json")) + candidates = registries_in(search) if candidates: try: data = json.loads(candidates[0].read_text(encoding="utf-8")) diff --git a/src/aipass/aipass/apps/handlers/structure_scan/structure_scanner.py b/src/aipass/aipass/apps/handlers/structure_scan/structure_scanner.py index 1640a2732..11186d570 100644 --- a/src/aipass/aipass/apps/handlers/structure_scan/structure_scanner.py +++ b/src/aipass/aipass/apps/handlers/structure_scan/structure_scanner.py @@ -21,6 +21,7 @@ from aipass.aipass.apps.handlers.json import json_handler from aipass.prax import logger +from aipass.aipass.shared.registry_discovery import registries_in class AgentInfo(NamedTuple): @@ -78,7 +79,7 @@ def find_project_root(start: Path) -> Optional[Path]: """ p = start.resolve() for parent in (p, *p.parents): - if list(parent.glob("*_REGISTRY.json")): + if registries_in(parent): return parent if (parent / "pyproject.toml").exists() and (parent / "src").is_dir(): return parent diff --git a/src/aipass/aipass/apps/modules/doctor.py b/src/aipass/aipass/apps/modules/doctor.py index fa77c8b96..78277bab4 100644 --- a/src/aipass/aipass/apps/modules/doctor.py +++ b/src/aipass/aipass/apps/modules/doctor.py @@ -78,6 +78,7 @@ detect_ram, detect_shell, ) +from aipass.aipass.apps.handlers.module_root import module_file from aipass.aipass.apps.handlers.ui.progress import ( GLYPH_FAIL, GLYPH_PASS, @@ -88,7 +89,8 @@ COMMAND = "doctor" -_BRANCH_ROOT = Path(__file__).resolve().parents[2] +# module_file, not resolve(): import-time cwd read on Windows (module_root). +_BRANCH_ROOT = module_file(__file__).parents[2] class CheckResult(NamedTuple): @@ -103,7 +105,7 @@ class CheckResult(NamedTuple): def _find_registry() -> Path | None: """Find *_REGISTRY.json via shared discovery (walk-up from CWD + branch root).""" result = _discover_registry(package_root=str(_BRANCH_ROOT)) - return result if result.exists() else None + return result if result is not None and result.exists() else None def _check_system() -> List[CheckResult]: diff --git a/src/aipass/aipass/apps/modules/init_flow.py b/src/aipass/aipass/apps/modules/init_flow.py index 84b8cdac2..8e0614f88 100644 --- a/src/aipass/aipass/apps/modules/init_flow.py +++ b/src/aipass/aipass/apps/modules/init_flow.py @@ -52,6 +52,8 @@ detect_tmux, detect_wt, ) +from aipass.aipass.apps.handlers.module_root import module_file +from aipass.aipass.shared.registry_discovery import registries_in try: import questionary as _questionary # type: ignore[import-untyped] @@ -65,7 +67,8 @@ COMMAND = "init" TOTAL_STAGES = 10 -_BRANCH_ROOT = Path(__file__).resolve().parents[2] +# module_file, not resolve(): import-time cwd read on Windows (module_root). +_BRANCH_ROOT = module_file(__file__).parents[2] def _get_local_json_path() -> Path: @@ -819,7 +822,7 @@ def run_init( # Ensure scaffold exists — only for aipass_framework cwd = Path.cwd() - if template == TEMPLATE_AIPASS and not list(cwd.glob("*_REGISTRY.json")): + if template == TEMPLATE_AIPASS and not registries_in(cwd): from aipass.aipass.apps.handlers.init.bootstrap import init_project if not dry_run: diff --git a/src/aipass/aipass/apps/modules/install.py b/src/aipass/aipass/apps/modules/install.py index 6f3e16c0d..32f3ce204 100644 --- a/src/aipass/aipass/apps/modules/install.py +++ b/src/aipass/aipass/apps/modules/install.py @@ -51,6 +51,7 @@ from aipass.aipass.apps.handlers.init.bootstrap import is_throwaway_path from aipass.aipass.apps.handlers.json import json_handler from aipass.aipass.apps.handlers.ui.progress import render_step_header +from aipass.aipass.shared.registry_discovery import registries_in COMMAND = "install" TOTAL_STEPS = 4 @@ -78,7 +79,7 @@ def _looks_like_aipass_tree(home: Path) -> bool: return False if (home / "setup.sh").is_file(): return True - return bool(list(home.glob("*_REGISTRY.json"))) + return bool(registries_in(home)) def _resolve_home(path: str | None, here: bool, non_interactive: bool) -> Path: diff --git a/src/aipass/aipass/apps/modules/profile.py b/src/aipass/aipass/apps/modules/profile.py index 8aab46481..b4472d9ce 100644 --- a/src/aipass/aipass/apps/modules/profile.py +++ b/src/aipass/aipass/apps/modules/profile.py @@ -34,9 +34,11 @@ from aipass.prax import logger from aipass.aipass.apps.handlers.json import json_handler +from aipass.aipass.apps.handlers.module_root import module_file COMMAND = "profile" -_BRANCH_ROOT = Path(__file__).resolve().parents[2] +# module_file, not resolve(): import-time cwd read on Windows (module_root). +_BRANCH_ROOT = module_file(__file__).parents[2] # NOT profile_data.json: _{config,data,log}.json is json_handler's OWN # managed triplet, and ensure_module_jsons REGENERATES any of the three that # fails its shape check. save_profile's own log_operation call auto-detects diff --git a/src/aipass/aipass/docs/test_quality_tooling_research.md b/src/aipass/aipass/docs/test_quality_tooling_research.md index 761f8ade7..c857a4b92 100644 --- a/src/aipass/aipass/docs/test_quality_tooling_research.md +++ b/src/aipass/aipass/docs/test_quality_tooling_research.md @@ -509,7 +509,7 @@ taxonomy asks for. cosmic-ray offers only killed/survived/incompetent/no-test. | # | Species | Ruff (MIT) | pylint (GPL-2.0) | Sonar Python (🔴 SSALv1) | PyNose (Apache-2.0, dead) | flake8-* | Verdict | |---|---|---|---|---|---|---|---| | 1 | **NO-ORACLE** | — | — | ◐ `S5899`,`S5918` | ● *Unknown Test* | — | **BUILD** | -| 2 | **TAUTOLOGY** | ● `PT015`,`B011`,`PLW0129`,`PLR0124`,`PLR0133`,`F631`,`B015` | ● `W0129`,`R0124`,`R0133`,`W0199` | ● **`S5914`** (best rule anywhere) | ● *Redundant Assertion* | — | **ADOPT + extend** | +| 2 | **TAUTOLOGY** | ● `PT015`,`B011`,`PLW0129`,`PLR0124`,`PLR0133`,`F631`,`B015` | ● `W0129`,`R0124`,`R0133`,`W0199` | ● **`S5914`** + 6 more test rules — see §0.1 | ● *Redundant Assertion* | — | **ADOPT + extend** | | 3 | **TYPE-ONLY** | — | — | — | — | — | **BUILD** (nothing exists, anywhere) | | 4 | **OR-ESCAPE** | — | — | ◐ | — | — | **BUILD** | | 5 | **MIRROR-EXPECT** | — | — | — | — | — | **BUILD** (hardest; needs import resolution) | @@ -537,7 +537,14 @@ taxonomy asks for. cosmic-ray offers only killed/survived/incompetent/no-test. remains LGPL-3.0 — **the Python rules are precisely the part that isn't open source.** Also verified by listing the rules tree: **`S2699` "Tests should include assertions" has NO Python implementation** — the one rule everyone assumes covers NO-ORACLE does not exist for Python. Its rule *specifications* are still the best in - the survey (see §7). + the survey (see §7). ⚠️ **CORRECTED in §0.1 — read that before quoting this bullet.** "No Python test-quality + rule beyond `S5914`" was too strong: `sonar-python` ships **seven** test rules, all default-on and all in the + **free LGPL-3.0 Community Build**, including `S5905` (assert on a tuple literal — always true, BUG/Blocker) + and `S5915` (assertion after a block expecting an exception — unreachable). **The verdict above does not + move**, and the reason is the useful part: **Ruff (MIT) already covers the same species** (`F631` ≈ `S5905`, + `PLW0129`/`B011` ≈ `S5914`'s family, `B017`/`PT010` ≈ `S5915`), so the SSALv1 analyzer offers no oracle + capability the OSS field lacks. `S2699`-has-no-Python-implementation is re-confirmed from the analyzer's own + rule resources. - 🔴 **CodeQL — not free for this use.** Free terms cover OSI-licensed codebases and academic research, and **explicitly do not authorize generating databases "for or during automated analysis, CI or CD."** A local `codeql` CLI in your own CI needs paid GitHub Advanced Security. It has no test-quality queries anyway. diff --git a/src/aipass/aipass/docs/test_suite_governance_research.md b/src/aipass/aipass/docs/test_suite_governance_research.md new file mode 100644 index 000000000..75a7db345 --- /dev/null +++ b/src/aipass/aipass/docs/test_suite_governance_research.md @@ -0,0 +1,484 @@ +# Test-suite governance — ranking, culling, and the AI angle + +**Author:** @aipass · **Date:** 2026-09-01 · **Assigned by:** Patrick, dispatched via @devpulse +**Round 2.** Round 1 is `docs/test_quality_tooling_research.md` (2026-08-29) and is the foundation; this +document does not re-research it. Where round 1 was wrong, §0.1 says so. + +**The question that changed.** Round 1 asked *can we measure test quality* and answered it. This round asks +a different question: **20,320 tests, most written by AI agents, and nobody can say which 5,000 matter.** +That is volume governance, not measurement. + +**Constraint honoured throughout:** OPEN-SOURCE / FREE only. Every tool named carries its license, verified +from a LICENSE file, the GitHub API `license.spdx_id`, or a PyPI license field — never guessed. Anything +proprietary, source-available-but-not-OSI, freemium, or license-absent is excluded. + +**Method:** four research sub-agents (culling precedent · the AI angle · per-test tooling feasibility · +seedgo's existing lane), which themselves spawned three more. Papers were read as full text via +`pdftotext`, not from search snippets — one agent explicitly caught its own PDF summariser inventing +plausible round numbers for the Google TAP paper and re-extracted. Tooling costs were **measured on this +machine against this fleet**. Measured figures are marked *(measured)*; scaled figures are marked +*(extrapolated)* with the basis stated; anything else is marked **unconfirmed**. + +**Resource note (round-1 lesson applied):** every agent was given an explicit budget — no installs, no +docker, no clones, scratch in `/tmp` and named. Nothing was installed. `/tmp/q1/` holds 48 MB of raw +measurement artifacts; the paper extracts are in `/tmp/*.txt`. The repo tree was verified unmodified by +`git status --porcelain` diff before and after. + +--- + +## 0. The five findings that should drive the design session + +**1. 🔴 The metric you would use to authorise a deletion does not predict the harm the deletion causes. +This is the single most important result in the literature and it refutes the obvious plan.** + +Shi et al. (ISSTA 2018) reduced test suites on 32 real projects, then replayed **1,478 real failed CI +builds** against the reduced suites. At the moment of reduction it looked nearly free: **51.9% of tests +removed for a 2.7% mutant-detection loss**. Measured against what actually happened next, those same suites +**missed between 26.1% and 52.2% of real failed builds**. Mutation-guided reduction — suites constructed to +kill *exactly the same mutants* — still missed **13.1% to 36.2%**. + +The predictors all fail: size reduction R² ≤ 0.26; mutant-detection loss R² = 0.25 (0.02–0.03 for most +variants). Their own sentence: *"mutation testing is not a good predictor of FBDL for reduced test +suites."* And the verdict: *"Automated TSR is more risky than suggested by prior research."* + +**Consequence: rank with mutation score, never authorise deletion with it.** A zero mutation-score delta +earns a test a demotion, not a grave. + +**2. Neither Google nor Meta deletes tests. Both run them less often. There is one published numeric +deletion rule on Earth and it has a human approval gate.** + +Google TAP (ICSE-SEIP 2017, 5,562,881 test targets): **91.3% of tests never failed once**; only **1.23%** +of executions ever caught a real breakage. Their remedy is explicitly *"re-executed less frequently"* — +MinDist ≤ 10 ran 61% of targets, saved 42% of resources, and *"did not miss a single breakage or fix."* +The word *delete* never appears as a remedy. Meta's predictive test selection accepts a <5% test-recall +miss **only because every test still runs in the stabilization stage every few hours**. + +The sole published numeric retirement rule found anywhere is **GitLab's**: fast quarantine 3 days → +long-term quarantine 3 months → 1-week deletion warning → removed. And GitLab's own text calls it +*"semi-automatic process, not fully automatic"* — a human merges the deletion MR. Mozilla, Chromium, +Google, Uber, LinkedIn, Slack, Dropbox, Shopify, Microsoft, Kubernetes and every commercial CI vendor +publish automation for *entering* quarantine and **nothing automated for retirement**. + +Every "delete after N quiet cycles" rule in circulation traces to vendor marketing with no data and no +organisation named. **If we adopt a numeric retirement rule we are ahead of published precedent, not +following it — and we should instrument accordingly.** + +**3. ✅ The enabling technology is free, and we already run the job it belongs in.** *(measured)* + +`coverage.py` dynamic contexts (`--cov-context=test`) record **which test covered which line**. The +surprise: **turning contexts on costs nothing.** In three of four paired runs on this fleet the context run +used *less* CPU than plain coverage — inside the ±13% noise floor of this box. The expensive step is +enabling coverage at all (+45% to +83%); attributing each line to a test on top of that is free. + +Mechanism verified in installed source, not guessed: `coverage/core.py:77` refuses `sys.monitoring` when +dynamic contexts are set, but `coverage/env.py:56` gates `sys.monitoring` on Python ≥ 3.14 — so on 3.10–3.13 +(what CI runs) the C tracer is used either way and there is no penalty. **This is a dated finding that flips +when the fleet moves to 3.14.** + +It survives xdist unchanged (measured: 207 contexts / 262 line_bits rows under `-n 4`, byte-identical to +serial). Database size **≈6 MB at 20k tests** (extrapolated from a measured marginal 293 B/test; 20–40 MB +if the fleet average is 3–5× heavier than the module measured). **`ci.yml:105` already runs a coverage +job.** This is a one-flag change to existing infrastructure. + +One trap: never call `coverage json --show-contexts` — measured **21.8 s and a 27.4 MB JSON from a 401 KB +database**. Read the SQLite directly with `coverage.numbits`: **0.02 s** for the same data. + +**4. The cheapest useful signal needs no infrastructure at all, and it already found something.** +*(measured, and independently re-measured by me with a separate implementation)* + +A pure-static pass over the fleet — `git blame` once per file plus AST line-range mapping, plus an +assertion-shape scan — takes **~43 seconds for all 20k tests** and needs no CI change, no plugin, no +coverage run. + +It has already produced: **478 test functions that contain no assertion, no `pytest.raises`, no `assert_*` +call, and no check of any kind** (my count over 626 files / 19,471 functions; the sub-agent's independent +run said 466 over 584 files / 18,283 functions — the gap is corpus definition, both ≈2.5%). Also ~465 +mock-assert-only change-detector risks, a full age distribution (p50 = 53 days, p90 = 149 days), and +authorship: **AIOSAI 15,146 tests, AIPass 3,036, humans 52**. + +Two findings fell out that nobody was looking for: **10 test files on disk are not in git at all**, and two +of them — `api/tests/test_devto_driver.py` and `api/tests/test_bluesky_driver.py` — **are collected and run +by CI with no history**. The other eight are under `.archive/` and excluded by `norecursedirs`. + +**5. Build it inside seedgo's lane — the argument is from what the lane already has, and the blocker is a +law, not architecture.** + +The lane **already enumerates every test in the fleet as a first-class object with a pytest nodeid** +(`corpus.py:77-95`, `TestUnit.nodeid`), **already emits a per-test row schema** (`corpus.py:355-377`: +`{species, file, line, nodeid, test, verdict, why, deletion_safety, evidence}`), and **already records the +full runtime nodeid order** (`executed_order`, 2,924 nodeids on seedgo) with per-(nodeid, phase) write +attribution. The join key, the enumerator and the row shape all exist and run today. + +What is missing is small and specific: (a) a row for *unflagged* tests — today only nominations survive +(163 nodeids published out of 2,639 scanned); (b) per-test outcome and duration, which is **two pytest +hooks** on a plugin that is already installed and already proven by canary; (c) history, which the artifact +model has none of. + +**The real blocker is Law S7a** (`laws.py:184-186`): any artifact where a non-`hygiene` group carries a +score is *refused*. And `ALLOWED_VERDICTS` is a closed set of four words with a `DELETE_FAMILY` +(`useless, delete, remove, worthless, dead`) refused outright. **A ranked inventory that published +anything resembling "this test is worthless" would be rejected by the lane's own validator — which, +given finding 1, is the lane being right.** + +--- + +## 0.1 Correction to round 1: the category is no longer empty + +Round 1's finding 1 read: *"There is no maintained, library-grade tool in any language that grades oracle +quality in Python."* **That claim was too strong, and it was already false when I wrote it.** Two +MIT-licensed Python tools existed on 2026-08-29 and I missed both. I verified them myself against the +GitHub API rather than taking the sub-agent's word: + +| Tool | What it does | License | Created | Last push | Stars | +|---|---|---|---|---|---| +| [`falsegreen`](https://github.com/vinicq/falsegreen) | *"Find false-green tests: tests that pass without verifying anything. Deterministic Python/pytest AST scanner (C1-C59), zero-dep, CI-ready."* | MIT (API `spdx_id`) | 2026-06-02 | 2026-08-11 | **2** | +| [`TestIQ`](https://github.com/pydevtools/TestIQ) | Duplicate/redundant test detection via per-test coverage overlap | MIT (LICENSE file read) | 2026-01-13 | **2026-01-18** | **5** | + +`falsegreen` is the closer miss and the more embarrassing one: its check family C is literally +*"Tests checking themselves, not the program (mocking the unit under test, self-confirming assertions)"* — +our exact problem statement — and its README says *"This matters more now that a large share of tests come +from AI assistants."* + +**The honest restatement: the category is EMBRYONIC, not empty.** `falsegreen` is alpha (`Development +Status :: 3 - Alpha`), v0.9.2, single-author, 2 stars, unproven at any scale, let alone 20,320 tests. +`TestIQ` had five days of development in January and nothing since, and its PyPI homepage URL 404s. Round +1's *conclusions* — that we are not duplicating a mature ecosystem, and that everything comparable and +load-bearing exists only for Java — still stand. The *sentence* did not, and the difference matters because +somebody could have installed `falsegreen` in five minutes at any point in the last three months. + +**Why I missed it:** round 1 searched for tools that grade *oracle quality* as a category term. `falsegreen` +markets itself on "false-green tests" and `TestIQ` on "duplicate tests" — neither uses the vocabulary I +searched. A negative result is only as good as the vocabulary it was checked against, and mine was one +vocabulary deep. + +**Also unchanged and worth restating:** Descartes / extreme mutation / pseudo-tested methods remain +**Java-only with no Python port**. That half of round 1's negative result was re-checked and holds. + +--- + +## 1. Q1 — The ranked inventory + +### 1.1 What it costs to compute each score component + +All costs measured on this machine (4 logical cores, Python 3.12.3) against this fleet unless marked. +Fleet ground truth *(measured)*: **20,320 tests collected in 24.78 s**; 584 collectable test files; +174,594 non-blank source lines. + +| Component | Tool | License | Cost at 20k | Cadence | +|---|---|---|---|---| +| Per-test runtime | pytest core hook (~20 lines) | MIT | **~0** — measured inside the noise band | Weekly, free rider | +| Per-test coverage footprint | `pytest --cov-context=test` | pytest-cov MIT, coverage Apache-2.0 | contexts **+0%** on top of coverage; DB ≈6 MB | **Weekly** | +| Redundancy / subset | ~150 lines over `coverage.numbits` | Apache-2.0 | **~16 s** with an inverted index | Weekly | +| Assertion vs implicit kill | `pytest_runtest_makereport` + `call.excinfo.typename` | MIT | ~0 — **but yields nothing on a green suite** | Weekly (static proxy) | +| Static assertion shape | AST scan | stdlib | **12.4 s** for all 18,283 functions | Weekly | +| Age & author | `git blame` per **file** + AST mapping | GPL-2.0 / PSF | **30.8 s** whole fleet | Weekly | +| Flakiness history | `pytest-reportlog` → own SQLite | MIT | sink ~0; **signal costs N× the suite** | Weekly accumulation, matures over weeks | +| Mutation-kill attribution | cosmic-ray (MIT) or mutmut 3 (BSD-3) | verified | **tens of CPU-hours** *(extrapolated from 174,594 LOC → ~150k–350k mutants)* | **One-off, sampled** | + +**Three traps found by measuring rather than assuming:** + +- **`--durations` is the wrong sink.** Output is `%.2f`-formatted — 60,960 lines of terminal text at 20k + scale with most tests rounding to `0.00s`. `TestReport.duration` carries full float precision + (measured: `0.00033979900763370097`). One hookwrapper gets duration *and* exception type together. +- **JUnit XML cannot carry the assertion/implicit split.** *(measured)* pytest emits **no `type=` + attribute on `` at all** — verified in `_pytest/junitxml.py`, `_add_simple` builds + `ET.Element(tag, message=message)`. Worse, `exconly(tryshort=True)` strips the `AssertionError:` prefix + specifically for rewritten bare asserts, so any prefix-parsing heuristic is asymmetric and wrong. JUnit + XML is structurally unusable here. `report.__dict__` *is* serialized, so a custom attribute rides + through xdist and `--report-log` for free. +- **`git log -L` per function is ~100× worse than blame-per-file.** *(measured)* 0.05–0.12 s each → + ~27 minutes for the fleet, versus **31 s** for blame-once-per-file plus AST line-range mapping. + +**And one trap in the redundancy signal itself, which is the finding that keeps this honest:** +*(measured on real data)* **39–75% of tests in the modules analysed execute a strict subset of another +test's lines.** Example: `test_help_flag_returns_zero ⊆ test_help_preempts_command_routing`. These are +**not deletable** — they assert different things about the same code path. Combined with finding 1, +**line-subset is a wildly over-eager retirement signal and must never be more than one column.** + +Algorithmically it is cheap if done right *(measured, synthetic 20k × 60k)*: inverted index on the +rarest covered line cuts 200M comparisons to **466k — a 430× reduction — total ~16 s**. Naive all-pairs is +22.4 min in pure Python and **20.3 min with 5.3 GB RSS in numpy** — vectorising bought nothing because the +problem is memory-bandwidth bound. **The algorithm is the win, not the array library.** + +### 1.2 Does a ranked-test-inventory tool already exist? + +**No.** Searched across five vocabularies (suite reduction/minimisation, test impact analysis, +coverage-context analysis, test smells, suite health). *"Test value score"* and *"ranked test inventory"* +are not established terms. + +**Every existing tool answers "which tests should I run right now?" Almost none answers "which tests should +still exist?"** The closest is `TestIQ`, whose score is **suite-level 0–100, not per-test**, and which uses +`sys.settrace()` (conflicting with coverage.py). Read it as a design reference, not a dependency. +Commercially the gap is nearly as wide: CircleCI Test Insights is literally a ranked list — **capped at 100 +rows, 14-day window**. The nearest published work is Apple's *Modeling and Ranking Flaky Tests* (ICSE-SEIP +2020) and Microsoft's THEO (ICSE 2015), the only formal per-test economic value model in the literature. +Both are papers, not tools. + +### 1.3 Recommendation: upgrade the lane, do not build a separate piece + +**Arguments from what the lane already has** (all file:line verified by a read-only sub-agent audit): + +1. `TestUnit` with a `nodeid` property already enumerates every test function — 2,639 on seedgo alone + (`corpus.py:77-95`, `:148-152`). +2. The per-test row schema already exists and is published (`corpus.py:355-377`). +3. **11 static nominator groups already emit per-test rows keyed by nodeid** — a fact the dispatch's + summary omitted, and the single most decision-relevant thing in the codebase. +4. The runtime side already has the join key: `executed_order` (2,924 nodeids), per-(nodeid, phase) + attribution (`payload:356`), `"attribution": "nodeid"` declared in `gatelog.py:176`. +5. The missing runtime fields are **two hooks**. The plugin implements `logstart/logfinish/setup/call/ + teardown` but not `pytest_runtest_logreport`. Outcome and duration are the cheapest possible addition to + a plugin already installed, already proven, already inside the gated child. +6. The isolation infrastructure a from-scratch tool would need is expensive and built: rsync copy-first + with a measured M10 proof, adapter contract, wall-clock budget, refusal vocabulary, atomic lawful + publication, and 4,907 lines of tests defending it. +7. Adding a static species is **three files and one line**, pinned by a test that asserts + `nominators.declared_groups() == sorted(adapter.STATIC_GROUPS)`. +8. **Cost is already sunk** — the fleet pass runs ~893 s anyway. A separate piece runs every suite a + second time. +9. **The lane's honesty machinery is a feature for this product, not friction.** Law S1 (`not_applicable` + with a reason, never 0), S8 (a scored group must declare its blind spots), M11 (`deletion_safety.probed: + false` on every row today). Given finding 1, a ranked inventory published *without* these is precisely + the confidently-wrong number that would get tests deleted. + +**The honest arguments against, which shape the design rather than defeat it:** + +- **Law S7a forbids a score outside `hygiene`**, and promotion into `SCORED_GROUPS` requires *"a measured + fleet-wide distribution of those counters in hand at ruling time"* plus a `gate_coverage.blind` list or + the artifact is refused. **This is a governance step, not a code step, and it should be taken + deliberately.** +- **No history.** One artifact per target, overwritten; the previous copy read only for the S3 group diff. + Flakiness ranking needs N runs. **History is a new store, not a new field.** +- **Granularity inversion downstream.** `exit_code_for()`, `render.py`, the fleet summary and the 5-rows- + per-group render cap are all written around one score per target. +- **Artifact size.** Already 6 KB–1.7 MB with ~163 flagged nodeids; a row per test is ~18× that. +- **Serial-only, and not CI's configuration.** The lane measures branch-rootdir serial and says so in + words; the design states the gate *cannot* run under xdist as built (module-singleton state, one log + path). **Per-test durations under the configuration people actually run cannot come from this harness + today.** +- Two execution groups (`scoped_survival`, `targeted_mutation`) are already queued with binding rev-4 + contracts. A ranked inventory would land ahead of sequenced work. + +**Proposed shape, which respects all of the above:** + +- **Phase A — outside the lane, this week.** The pure-static pass (§0 finding 4). 43 seconds, no CI change, + no governance event, and it has already produced 478 actionable rows. Ship it as a report, not a verdict. +- **Phase B — one flag on an existing job.** Add `--cov-context=test` to the CI coverage job and start + writing the per-test line-set database. Note: that job currently uses `coverage run -m pytest` + (`ci.yml:105`), which must become `pytest --cov=... --cov-context=test` or **parametrized cases collapse** + — pytest-cov keys contexts on the full nodeid including `[param]`, coverage.py's own `dynamic_context = + test_function` does not. +- **Phase C — two hooks in the lane's payload.** `pytest_runtest_logreport` for outcome, duration and + `excinfo.typename`. Emit a row for **every** test, not only nominations. +- **Phase D — the governance step.** Take the S7a/S8 ruling deliberately, with the fleet-wide distribution + in hand, and publish the score as a **rank with declared blind spots** — never a delete verdict. The + closed `ALLOWED_VERDICTS` set should stay closed. + +--- + +## 2. Q2 — Culling precedent and the safe-deletion ladder + +### 2.1 What the big shops actually do + +| Org | Mechanism | Retirement rule | +|---|---|---| +| **Google TAP** | MinDist run-less-often; auto-quarantine on flakiness | **None published.** No threshold, no timeout, no deletion rule | +| **Meta** | 4-stage: pre-submit → diff-time ML → land-time ML → **stabilization runs everything** | **N/A — no test ever leaves** | +| **GitLab** | fast quarantine 3d → long-term 3mo → 1wk warning → removed | **The only published numeric rule. Human-merged.** | +| **Mozilla** | Stockwell: 150 failures/21d → disable-recommended; 75/wk → 1 week; 30/wk → 2 weeks. Tier 1/2/3 visibility. ML scheduler −70% tasks, backstop every 20 pushes / 4 hours | Disable, never delete. `StaleTestExpectations` is a manual graveyard, quantifier *"many months"* | +| **Chromium** | `DISABLED_`, expectations files | **Mechanism documented, no policy.** No deadline, no threshold, no re-enable procedure | +| **LinkedIn** | *"if a test passed before but failed later… should be disabled"* — auto-disable on a single flake | **No restoration rule, no deletion rule** | +| **Slack** | Auto-disable by renaming, auto-opened auto-approved PR into merge queue | Restoration was explicitly future work | +| **Uber** | Has a `deleted` state | **Administrative only — no timer or threshold drives it** | +| **Microsoft** | Quarantine suppresses the failure, test still runs | *"will continue to treat it as such until it is manually unmarked"* | +| **Vendors** (Buildkite, Trunk, Datadog, Develocity, CircleCI) | Automated entry *and* automated exit | **No vendor product can delete a test** | + +**The design principle worth stealing:** every automatic *release* rule in the survey is keyed on +**continued observation**, not elapsed time — Buildkite's *"seven days or 100 executions"*, Uber's +*"100 consecutive successful runs"*, GitLab's *">100 local runs"* dequarantine bar. **A test that stops +running cannot earn its way back.** Any "delete after N quiet cycles" rule inherits this directly: if +quarantine means *skip*, quiet cycles measure nothing and the rule degenerates into "delete after N days." +GitLab is the one org that took that trade explicitly — and paired it with a human gate and a warning week. + +### 2.2 Selection beats deletion on the published evidence + +This is worth stating plainly because it reframes the whole request. Shi et al. (ESEC/FSE 2015) put the two +head to head: **regression test selection ran on average 40.15pp FEWER tests than suite reduction, while +safe selection cannot miss any change-related fault the full suite would find.** Reduction missed up to +5.93% of change-related mutants. The authors' phrase is *"the peril of using test-suite reduction."* + +And the theory says this will not improve: Yoo & Harman's survey concludes that minimisation *"would not be +safe unless the surrogate metric perfectly captures the fault detection capability… the empirical studies +so far have shown that there is no such single metric."* **Selection has a formal safety definition. +Minimisation does not and cannot.** That is why Google and Meta both built selection systems and neither +built a minimisation system. + +**The one hopeful number, and it is a taunt:** Shi et al.'s oracular reduction — knowing every future +failure in advance — needed only **~20% of the suite and missed nothing**. So ~80% of a real suite genuinely +is dead weight, and **no published method can identify which 80% in advance.** That is exactly the gap this +fleet sits in. + +### 2.3 The ladder + +Steps 0–4 are copied from documented industrial practice. Steps 5–6 go beyond published precedent and are +marked as such. + +**Step 0 — Stop the inflow. This is the only step with unambiguous support and it is already ruled.** +Patrick's rule — never add a test without a defect it pins — is the highest-leverage action available, +because everything downstream of it is statistically unreliable. Enforce at review: every new test names +the defect it pins. Free, zero false-negative risk. + +**Step 1 — Instrument before touching anything.** Per test per run: pass/fail/flake/skip, commit, duration, +exception type. Two mandatory refinements: **separate flakes from failures by retry** (Meta reruns up to +10× — without it, "has ever failed" is contaminated; at Google **40.5%** of ever-both-passed-and-failed +targets were flakes), and **record per-test coverage attribution** via contexts. Budget 60–90 days before +the signal is usable; Meta trains on 3 months. + +**Step 2 — Rank. Delete nothing.** Publish the ranking. Expect the Google/Shi shape: ~90% never caught +anything, ~20% would have sufficed in hindsight. + +**Step 3 — Shadow-run the proposed cut against history.** This is the step that catches the mistake, and it +is the only metric with meaningful predictive power (historical FBDL, R² = 0.57 — still weak, and still the +best there is). Replay the reduced suite against every recorded failed build; compute our own FBDL. Run +Microsoft's two-task pattern continuously: T1 = reduced, T2 = full, alert on divergence. **Do not proceed +past this step on any candidate whose historical FBDL is non-zero.** + +**Step 4 — Demote to a reduced-frequency lane. Still not deletion.** This captures essentially all of the +cost saving — Google got **42–55% resource savings** this way while missing *zero* breakages, because the +tests still ran, just later. Keep an unconditional full run on a fixed cadence. **For a 20,320-test suite +this is where the ladder should stop for the foreseeable future.** + +**Step 5 — Quarantine, with a human exit and a bug.** Reserve it for *flaky*, not merely low-value — they +are different diseases. The governing number: **1 in 6 newly-flaky tests is a real production bug** +(Google, 2017, over 4.2M tests). A quarantine chute with nobody reading the bugs converts a sixth of its +contents into silent production defects. + +**Step 6 — Delete only on a *reason*, never on a *statistic*. [NO INDUSTRY PRECEDENT — ours to invent]** +- Code under test is gone → delete. Safe and uncontroversial. +- Provable duplicate: same call path, same assertions, no exclusive coverage, zero mutation delta → delete + in a **separate revertible commit, one cluster at a time**. +- Asserts something no longer required → delete, and say so in the message. +- **"It has never failed" is grounds for demotion, never deletion.** At Google that describes 91.3% of all + tests, including the ones that later caught things. + +If a time-based rule is adopted, adopt it as an **audit trigger** — "after 2 quiet quarters a human must +justify keeping it" — not an auto-delete. **Asymmetry to keep in view: demotion is cheap to reverse; +deletion destroys the knowledge of why the test existed, and in an AI-written suite that knowledge may +never have been written down anywhere.** + +--- + +## 3. Q3 — The AI angle: yes, it is named, as of five months ago + +**The institutional naming.** Thoughtworks Technology Radar Vol. 34 (April 2026), *Mutation testing* blip, +ring **Trial**: + +> *"With AI-generated test cases now commonplace, mutation testing acts as a reinforcement layer for +> catching **'perpetually green' tests** — those that pass regardless of logic changes due to missing +> assertions or decoupled mocks."* + +That is our exact problem, named by a citable institution, with the remedy prescribed. Note the Radar names +Stryker, Pitest and cargo-mutants — **no Python tool**, which is itself a finding. + +**The academic naming**, mid-2026, and the numbers are unflattering: + +- **"All Smoke, No Alarm: Oracle Signals in Agent-Authored Test Code"** (arXiv:2606.18168) — + **86,156 test patches from 33,596 agent-authored PRs** across five agents including Claude Code. + **80.2% contain weak or no explicit oracle signals.** Strong-oracle rate ranges 18% (Codex) to 67% + (Claude Code). Their taxonomy W1–W5 includes **W4: mock/call-verification only** — the "test defends the + test" species — and is directly implementable as a static check. +- **"Beyond Test Presence"** (arXiv:2607.12068) — 204,673 artifacts, **Python is the largest agent slice at + 35.7%**. Agent "unrecognized assertion patterns" **10.93% vs human 1.46% — roughly 8×**. Coins + **"stealth technical debt."** Honest counterpoint: agents *beat* humans on edge-case variety (0.62 vs + 0.32). +- **"Do LLMs generate test oracles that capture the actual or the expected program behaviour?"** + (arXiv:2410.21136) — assertion classification accuracy 40.8–46.3%, **dropping a further 8.4–9.5% exactly + when the code is buggy**. The model follows the implementation rather than the intent. Mutation score of + LLM oracles: **19.10%**. +- **"Rethinking the Value of Agent-Generated Tests"** (arXiv:2602.07900) — resolved and unresolved SWE-bench + tasks showed *similar* test-writing frequencies; *"value-revealing print statements appear much more often + than assertion-based checks"*; prompting for more or fewer tests *"does not significantly change final + outcomes."* Conclusion: *"current agent-written testing practices reshape process and cost more than final + task outcomes."* **This is the strongest published support for the hypothesis that an AI campaign's test + output is process exhaust.** +- **Coverage is the wrong metric for AI suites specifically** (arXiv:2607.22880, ISSTA 2026, 101,123 test + cases): coverage↔mutation r ≤ 0.443, and coverage *"loses predictive power for bug detection"* precisely + on buggy code. + +**The gap we could fill:** **no study measures redundancy *between* LLM-generated tests**, and nobody has +measured the specific species suspected here — tests asserting on other tests, fixtures, or the harness +rather than production code. The nearest operationalisation is TestPilot's *"non-trivial assertion"* +(does the assertion depend on a function from the package under test?) — under which **a median 38.6% of +its generated tests assert nothing touching the code under test; 91% in the worst package.** Implementing +that check against this fleet would be measuring something the literature has not. + +**A cautionary case study, because it is the exact mistake available to us.** The one published AI-test- +sprawl case study with numbers (Oliphant, 2026-03-24) reports *"287 of those 489 tests (78%) covered zero +unique lines"* and *"ForkHub went from 489 tests to 119. A 76% reduction with zero coverage loss."* +**Two problems: the arithmetic is wrong** (287/489 = 58.7%), **and they measured coverage only and never ran +mutation testing.** "Zero coverage loss" does not establish zero fault-detection loss — and per §2.2 and +finding 1, unique-line coverage is the *worst* available deletion criterion. **Do not replicate this +method.** + +--- + +## 4. What nobody has solved + +1. **No published method predicts which tests can safely be deleted.** Every predictor tested — size + reduction, coverage loss, mutant-detection loss — was weak (R² ≤ 0.26). The best, historical FBDL, + reaches 0.57 and its own authors call it *"not strong in most cases."* +2. **The 80/20 gap is provable in hindsight and unreachable in advance.** +3. **No organisation has published a data-backed numeric retirement policy** — GitLab's is published but + human-gated and unaccompanied by outcome data. Whether such rules work is genuinely unknown. +4. **No published unsafety rate for any test-selection system in production.** Meta publishes a calibrated + design target, not an observed escape rate. Microsoft publishes a validation procedure and no results. +5. **Flakiness contaminates every history-based signal** and no retry scheme fully removes it; Meta calls + its own retry-based estimate *"a lower-bound."* +6. **`pytest-testmon` — the most relevant Python TIA tool — has no published miss rate or benchmark at + all.** Its safety rests on four self-declared assumptions plus coverage.py's own documented blind spots. +7. **Every TIA system shares one blind spot: non-source files.** testmon says so about itself; RTSCheck's + bug `All-1` says so about all three Java tools it checked. Fixtures, golden files, JSON/YAML config and + test data are invisible to all of them. +8. **Nothing is published about culling AI-generated suites specifically.** The LLM test-smell literature + measures *quality*; nobody has studied whether AI-written tests are *more* safely deletable than human + ones — even though the "why did this exist" knowledge that makes deletion risky may never have existed + for them. **Our situation is not covered by the literature.** + +--- + +## Sources + +Papers read as full text: Memon et al. [*Taming Google-Scale Continuous Testing*](https://dl.acm.org/doi/10.1109/ICSE-SEIP.2017.16) (ICSE-SEIP 2017) · +Machalica et al. [*Predictive Test Selection*](https://arxiv.org/abs/1810.05286) (ICSE-SEIP 2019) · +Shi et al. [*Evaluating Test-Suite Reduction in Real Software Evolution*](https://mir.cs.illinois.edu/marinov/publications/ShiETAL18TSRinReal.pdf) (ISSTA 2018) · +Shi et al. [*Balancing Trade-offs in Test-Suite Reduction*](https://mir.cs.illinois.edu/awshi2/publications/FSE2014.pdf) (FSE 2014) · +Shi et al. [*Comparing and Combining Test-Suite Reduction and RTS*](https://mir.cs.illinois.edu/marinov/publications/ShiETAL15ReductionSelection.pdf) (ESEC/FSE 2015) · +Yoo & Harman [*Regression testing minimisation, selection and prioritisation*](https://onlinelibrary.wiley.com/doi/abs/10.1002/stvr.430) (STVR 2012) · +Inozemtseva & Holmes [*Coverage Is Not Strongly Correlated…*](https://www.cs.ubc.ca/~rtholmes/papers/icse_2014_inozemtseva.pdf) (ICSE 2014) · +Zhang & Mesbah [*Assertions Are Strongly Correlated…*](https://people.ece.ubc.ca/~amesbah/resources/papers/fse15.pdf) (ESEC/FSE 2015) · +Gligoric et al. [*Ekstazi*](https://mir.cs.illinois.edu/marinov/publications/GligoricETAL15PracticalRTS.pdf) (ISSTA 2015) · +Zhu et al. [*RTSCheck*](https://users.ece.utexas.edu/~gligoric/papers/ZhuETAL19RTSCheck.pdf) (ICSE 2019) · +Petrović et al. [*Practical Mutation Testing at Scale*](https://arxiv.org/pdf/2102.11378) (IEEE TSE 2021). + +AI-angle: [arXiv:2606.18168](https://arxiv.org/abs/2606.18168) · [arXiv:2607.12068](https://arxiv.org/html/2607.12068v1) · +[arXiv:2607.22880](https://arxiv.org/abs/2607.22880) · [arXiv:2602.07900](https://arxiv.org/abs/2602.07900) · +[arXiv:2410.21136](https://arxiv.org/abs/2410.21136) · [arXiv:2410.10628](https://arxiv.org/abs/2410.10628) (TOSEM) · +[Thoughtworks Radar Vol 34 — Mutation testing](https://www.thoughtworks.com/radar/techniques/mutation-testing). + +Policy: [GitLab quarantine process](https://handbook.gitlab.com/handbook/engineering/testing/quarantine-process/) · +[Mozilla Stockwell](https://wiki.mozilla.org/Auto-tools/Projects/Stockwell/Robot) · +[Chromium on_disabling_tests](https://chromium.googlesource.com/chromium/src/+/main/docs/testing/on_disabling_tests.md) · +[Google flaky tests](https://testing.googleblog.com/2016/05/flaky-tests-at-google-and-how-we.html) · +[Dropbox Athena](https://dropbox.tech/infrastructure/athena-our-automated-build-health-management-system) · +[Slack auto-suppression](https://slack.engineering/handling-flaky-tests-at-scale-auto-detection-suppression/). + +Tools verified this round: [falsegreen](https://github.com/vinicq/falsegreen) (MIT) · +[TestIQ](https://github.com/pydevtools/TestIQ) (MIT) · [coverage.py contexts](https://coverage.readthedocs.io/en/latest/contexts.html) · +[pytest-testmon](https://testmon.org/blog/determining-affected-tests/) (MIT). + +**Claims explicitly NOT verified and recommended against publishing:** Chromium "delete after 10K/20K +revisions"; Microsoft's ~49,000 flaky tests (the cited devblogs post 404s); Elastic's muted-then-removed +policy; Spotify Master Guardian "skips" pre-merge (its own deck says *retries*); Kafka's 10% threshold; all +vendor-blog quarantine SLA numbers (2% flake threshold, "max stay two to four weeks", "escalate after 30 +days") — these appear only on commercial marketing pages with no organisation named. The widely-repeated +*"flaky tests cost Google 16% of developer time"* is a **distortion**: the primary source says 16% of +*tests* have some flakiness. There is no primary source for a 16%-of-developer-time claim. diff --git a/src/aipass/aipass/shared/json_handler.py b/src/aipass/aipass/shared/json_handler.py index 5f82117ba..1f7687b44 100644 --- a/src/aipass/aipass/shared/json_handler.py +++ b/src/aipass/aipass/shared/json_handler.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: json_handler.py # Description: Shared JSON handler with injectable storage directory -# Version: 1.1.0 +# Version: 1.2.0 # Created: 2026-06-06 -# Modified: 2026-08-18 +# Modified: 2026-08-31 # ============================================= """Shared JSON handler — auto-creating, self-healing JSON system. @@ -13,15 +13,19 @@ Each branch creates a JsonHandler instance with its own json_dir. Contract: save_json raises ValueError on validation failure. -This module is the single write path behind the @spawn, @memory and @aipass -json_handler shims, so its swap carries three branches at once — see -_replace_with_retry. +This module is the single write path behind five branch json_handler shims — +@aipass, @canary, @memory, @spawn, plus spawn's citizen TEMPLATE, so every +citizen minted from here on inherits whatever this file does. Measured +2026-08-31 by grepping importers fleet-wide; the line above used to name three +and predated @canary and the template. Any change here carries all five at +once — see _replace_with_retry, and _get_caller_module_name, which four of the +five reach without a local copy. """ -import inspect import json import logging import os +import sys import tempfile import time from datetime import datetime @@ -337,11 +341,36 @@ def log_operation(self, operation: str, data: Dict[str, Any] | None = None, modu def _get_caller_module_name() -> str: - """Auto-detect calling module name from call stack.""" - stack = inspect.stack() - if len(stack) > 2: - caller_path = Path(stack[2].filename) - module_name = caller_path.stem - if module_name and not module_name.startswith("_"): - return module_name + """Auto-detect calling module name from call stack. + + Walks the frame chain with ``sys._getframe`` rather than + ``inspect.stack()``. MEASURED 2026-08-31 (@canary's exposure report, + @devpulse's round-4 follow-up): ``inspect.stack()`` builds a FrameInfo per + frame, and for any frame whose filename is not on disk — ```` from + a ``-c`` command-line source, a ``compile()``d source, the frozen + importlib frames — + ``getsourcefile()`` falls through to ``getmodule()``, whose module-scanning + loop calls ``os.path.realpath`` OUTSIDE the ``try`` that wraps + ``getabsfile``. ``ntpath.realpath`` then reads ``os.getcwd()`` + unconditionally. + + On this hot path that made LOGGING TAKE DOWN THE CALLER IT LOGS FOR: + ``log_operation`` resolves the module name BEFORE its own ``try``, so the + raise escaped every handler below it. A frame's ``co_filename`` is already + a string in memory; reading it touches no filesystem. + + Returns: + The calling module's filename stem, or ``"unknown"`` when the stack is + too shallow or the name is private. Behaviour is unchanged from the + ``inspect.stack()`` form — only the mechanism moved. + """ + try: + frame = sys._getframe(2) + except ValueError: + # Stack shallower than the caller-of-log_operation depth; the + # inspect.stack() form spelled this as `len(stack) > 2`. + return "unknown" + module_name = Path(frame.f_code.co_filename).stem + if module_name and not module_name.startswith("_"): + return module_name return "unknown" diff --git a/src/aipass/aipass/shared/registry_discovery.py b/src/aipass/aipass/shared/registry_discovery.py index 2f111f688..f065da843 100644 --- a/src/aipass/aipass/shared/registry_discovery.py +++ b/src/aipass/aipass/shared/registry_discovery.py @@ -15,8 +15,51 @@ from pathlib import Path +_REGISTRY_SUFFIX = "_REGISTRY.json" +_REGISTRY_GLOB = "*" + _REGISTRY_SUFFIX + + +def registries_in(directory): + """Every ``*_REGISTRY.json`` in *directory*, exact-case, sorted. + + THE GLOB IS NOT THE FILTER. ``Path.glob`` asks the FILESYSTEM to match, and + on a case-insensitive one — Windows, and macOS by default — ``*_REGISTRY.json`` + also matches ``*_registry.json``. The bait ships in every branch: + ``flow_json/*_registry.json`` plan counters and a ``.spawn/.template_registry.json`` + (pathlib's ``*`` matches dotfiles, unlike the ``glob`` module). Measured on + Windows CI: ``find_registry()`` returned ``drone_command_registry.json``. + + A registry file is a project's TRUST ANCHOR — it decides which installation + a caller belongs to, which project name gets stamped on their identity, and + where the delete lane thinks the project root is. A plan-id counter served + in that role answers a question it was never asked. + + So the name is re-checked in Python, where ``str.endswith`` is case-sensitive + on every platform. SUFFIX only, never the stem: external projects name their + registry after themselves (``VERA-STUDIO_REGISTRY.json``) and nothing + promises the stem is uppercase. + + One reader, called from every walk in this branch — the eighth private copy + of ``glob("*_REGISTRY.json")`` is how a fix lands on some of N identical + paths. ``tests/test_registry_case_sweep.py`` pins that by AST so a new + private copy cannot be added quietly. + + Args: + directory: Path to search in. + + Returns: + Sorted list of registry paths; empty when the directory is absent or + unreadable (a directory we cannot read holds no anchor we can trust). + """ + try: + entries = list(directory.glob(_REGISTRY_GLOB)) + except (OSError, ValueError): + return [] + return sorted(p for p in entries if p.name.endswith(_REGISTRY_SUFFIX)) + + def _glob_registry(directory): - """Find *_REGISTRY.json in a single directory. + """Find the first *_REGISTRY.json in a single directory. Args: directory: Path to search in. @@ -24,7 +67,7 @@ def _glob_registry(directory): Returns: Path to the registry file, or None if not found. """ - matches = sorted(directory.glob("*_REGISTRY.json")) + matches = registries_in(directory) return matches[0] if matches else None @@ -35,17 +78,29 @@ def find_registry(start_path=None, package_root=None): If multiple exist in the same directory, picks the first alphabetically. Priority: - 1. AIPASS_REGISTRY environment variable + 1. AIPASS_REGISTRY environment variable — an explicit instruction, returned + unchecked: an operator who names a path is not guessing. 2. Walk up from start_path/cwd — first dir containing *_REGISTRY.json 3. Walk up from package_root (caller's __file__ location) — fallback - 4. Last resort: cwd / AIPASS_REGISTRY.json (backwards compat) + 4. Absence — return None. + + On (4) this used to return ``Path.cwd() / "AIPASS_REGISTRY.json"``. That + was a guess about where the caller happens to stand, dressed as a fact + about the machine, and it DISCARDED the ``start_path`` it was asked about: + a caller asking "is there a registry under /x" was answered with a file in + the process's cwd. Every consumer here guards with ``.exists()``, so + nothing fired — but a path that need not exist is a lie with a type + signature, and @spawn's ``load_registry`` mints a fresh ``metadata.id`` + for exactly such a path. Absence is a fact; say it as one and let the + caller refuse by name. (Sibling precedent already in this branch: + ``apps/handlers/init/git_auth.py:find_registry`` returns ``Optional[Path]``.) Args: start_path: Directory to start searching from (default: cwd). package_root: Optional fallback directory for package-relative search. Returns: - Path to *_REGISTRY.json. + Path to *_REGISTRY.json, or None when no registry was found. """ env_path = os.environ.get("AIPASS_REGISTRY") if env_path: @@ -64,4 +119,4 @@ def find_registry(start_path=None, package_root=None): if found: return found - return Path.cwd() / "AIPASS_REGISTRY.json" + return None diff --git a/src/aipass/aipass/tests/test_import_dead_cwd.py b/src/aipass/aipass/tests/test_import_dead_cwd.py new file mode 100644 index 000000000..66d90b2fe --- /dev/null +++ b/src/aipass/aipass/tests/test_import_dead_cwd.py @@ -0,0 +1,455 @@ +# =================== AIPass ==================== +# Name: test_import_dead_cwd.py +# Description: Pins every aipass module against an unreadable working directory +# Version: 1.1.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""No module in this branch may read the working directory to import. + +THE MECHANISM (measured on the Windows CI gate 2026-08-31, @memory's finding) +---------------------------------------------------------------------------- +``ntpath.realpath`` computes ``os.getcwd()`` UNCONDITIONALLY — on its first +lines, before it checks whether the path is even absolute — and +``Path.resolve()`` routes through it. So on Windows every +``Path(__file__).resolve()`` reached while a module is being imported is an +import-time working-directory read, and a process whose cwd was deleted (or +sits on a disconnected share) cannot import the module at all. + +``posixpath.realpath`` skips ``getcwd`` for an absolute path, which is why this +was invisible on Linux for as long as it existed. These pins inject the +Windows behaviour as a CONDITION rather than a platform, so they run red on +this host: ``os.path.realpath`` is wrapped to read ``os.getcwd()`` first, then +``os.getcwd`` is denied. + +THE DISCRIMINATOR IS *REACHED AT IMPORT*, NOT *WRITTEN AT MODULE SCOPE* +----------------------------------------------------------------------- +A ``resolve()`` inside a function still counts when import-time code calls that +function — including a default argument evaluated during import. A grep for +module-level assignments would miss those, so the pin imports every module in +the tree and lets the interpreter decide what is reached. + +WHY A CHILD PROCESS +------------------- +The injection has to land before any ``aipass`` module is imported, or a module +that already cached ``os.path.realpath`` would be measured against the real +one. Other branches' import-time code is held CONSTANT by preloading it in the +healthy world first: their dead-cwd cure is their own build, and this file +measures aipass's sites only. +""" + +from __future__ import annotations + +import ast +import subprocess +import sys +from pathlib import Path + +BRANCH_ROOT = Path(__file__).resolve().parents[1] + +#: Cross-branch modules this tree imports. Preloaded in the healthy world so a +#: red here is always an aipass site. (Found by grepping ``from aipass.`` +#: across apps/ and shared/ — see the module docstring.) +_FOREIGN = [ + "aipass.prax", + "aipass.prax.apps.modules.logger", + "aipass.cli.apps.modules", + "aipass.hooks.apps.handlers.json", + "aipass.hooks.apps.handlers.config", + # trust_registry pulls hooks' own json_handler, whose module-level + # Path(__file__).resolve() is the same species at hooks/.../json_handler.py:30. + # Reported to @hooks 2026-08-31; their site, their build. Preloading it keeps + # this pin measuring aipass — drop the line when hooks is cured. + "aipass.hooks.apps.handlers.config.trust_registry", + "aipass.trigger.apps.modules.core", +] + +_SKIP_PARTS = {".archive", ".backup", "__pycache__", "tests", "logs"} + + +def _branch_modules() -> list[str]: + """Every importable ``aipass.aipass.*`` module name, found on disk. + + Named by walking files rather than by ``pkgutil``, because ``pkgutil`` + imports as it walks — the very thing under test. + """ + names: list[str] = [] + for py in sorted(BRANCH_ROOT.rglob("*.py")): + parts = py.relative_to(BRANCH_ROOT).parts + if any(part in _SKIP_PARTS for part in parts): + continue + stem = parts[:-1] if parts[-1] == "__init__.py" else (*parts[:-1], parts[-1][:-3]) + names.append(".".join(("aipass", "aipass", *stem))) + return names + + +_DEAD_CWD_WORLD = """ +import os, sys, importlib + +# Other branches' import-time code, held constant in the healthy world. +for _foreign in {foreign!r}: + try: + importlib.import_module(_foreign) + except Exception: + pass + +_real_realpath = os.path.realpath + + +def _ntpath_condition(path, **kw): + os.getcwd() # ntpath.realpath reads cwd before checking absoluteness + return _real_realpath(path, **kw) + + +os.path.realpath = _ntpath_condition + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd + +# Probe the instrument: does THIS interpreter route an absolute resolve() +# through os.path.realpath? 3.11+ does; 3.10 resolves absolute paths without +# touching cwd, so the denial could not fire there. +import pathlib + +try: + pathlib.Path(pathlib.__file__).resolve() + print("PROBE_VACUOUS") +except FileNotFoundError: + print("PROBE_ARMED") + +for _name in {modules!r}: + try: + importlib.import_module(_name) + except Exception as exc: + print("FAIL %s | %s: %s" % (_name, type(exc).__name__, exc)) + +print("SWEPT") +""" + + +def _run(source: str) -> str: + result = subprocess.run( + [sys.executable, "-c", source], + capture_output=True, + text=True, + timeout=180, + ) + return result.stdout + result.stderr + + +class TestEveryModuleImportsWithoutTheWorkingDirectory: + def test_no_module_reads_cwd_to_import(self) -> None: + out = _run(_DEAD_CWD_WORLD.format(foreign=_FOREIGN, modules=_branch_modules())) + assert "SWEPT" in out, f"the sweep itself died:\n{out}" + failures = [line for line in out.splitlines() if line.startswith("FAIL ")] + assert failures == [], "module(s) need a readable cwd to import:\n" + "\n".join(failures) + + def test_the_instrument_is_armed(self) -> None: + """A vacuous probe would make the sweep above green for free.""" + out = _run(_DEAD_CWD_WORLD.format(foreign=_FOREIGN, modules=[])) + if "PROBE_VACUOUS" in out: + assert sys.version_info < (3, 11), ( + "resolve() survived the denial on an interpreter that routes through " + "os.path.realpath — the instrument is broken, not the world" + ) + else: + assert "PROBE_ARMED" in out, f"probe reported neither outcome:\n{out}" + + def test_the_sweep_actually_found_modules(self) -> None: + """A walk that names nothing reports clean. Refuse to call that a pass.""" + modules = _branch_modules() + assert len(modules) > 40, f"module walk named only {len(modules)} — it is blind, not clean" + assert "aipass.aipass.apps.handlers" in modules + assert not any(".tests" in name or ".archive" in name for name in modules) + + +# --------------------------------------------------------------------------- +# the guard specifically — inspect.stack() needs the filesystem +# --------------------------------------------------------------------------- + +_REALPATH_DENIED_WORLD = """ +import os, sys, importlib + +for _foreign in {foreign!r}: + try: + importlib.import_module(_foreign) + except Exception: + pass + +import inspect + + +def _denied(path, *a, **kw): + raise FileNotFoundError(2, "realpath denied", str(path)) + + +os.path.realpath = _denied + +{body} +""" + +_INSTRUMENT_BITES = """ +try: + inspect.stack() + print("STACK_SURVIVED") +except FileNotFoundError: + print("STACK_RAISED") +""" + +_GUARD_IMPORTS = """ +try: + importlib.import_module("aipass.aipass.apps.handlers") + print("GUARD_OK") +except Exception as exc: + print("GUARD_DIED %s: %s" % (type(exc).__name__, exc)) +""" + + +class TestTheImportGuardWalksFramesWithoutTheFilesystem: + """``inspect.stack()`` builds a FrameInfo per frame. + + For a frozen-importlib frame the filename does not exist on disk, so + ``getsourcefile`` falls through to ``getmodule()`` — whose module-scanning + loop calls ``os.path.realpath`` OUTSIDE the ``try`` that wraps + ``getabsfile``. On POSIX the equivalent raise happens earlier, inside + ``getabsfile``'s ``abspath`` → ``getcwd``, where ``inspect`` catches it. On + Windows ``ntpath.abspath`` succeeds and the unguarded ``realpath`` runs. + + So the Windows-faithful denial on this host is of ``os.path.realpath``, not + of ``os.getcwd``: a getcwd denial alone is green against the defective + guard, because posixpath skips getcwd for absolute paths (@ai_mail's + instrument note, 2026-08-31). + """ + + def test_inspect_stack_raises_in_this_world(self) -> None: + """Positive control. Without this the pin below could pass blind.""" + out = _run(_REALPATH_DENIED_WORLD.format(foreign=_FOREIGN, body=_INSTRUMENT_BITES)) + assert "STACK_RAISED" in out, f"the denial did not reach inspect.stack — instrument is blind:\n{out}" + + def test_the_handlers_guard_imports_anyway(self) -> None: + out = _run(_REALPATH_DENIED_WORLD.format(foreign=_FOREIGN, body=_GUARD_IMPORTS)) + assert "GUARD_OK" in out, f"the import guard needs the filesystem to run:\n{out}" + + +# --------------------------------------------------------------------------- +# the structural ban -- inspect.stack() must be unwritable in this tree +# --------------------------------------------------------------------------- + +GUARD = BRANCH_ROOT / "apps" / "handlers" / "__init__.py" + +#: Directories the ban does not police: retired code and the pin's own fixtures. +_BAN_SKIP = {".archive", ".backup", "__pycache__", "tests", "logs"} + + +def _inspect_stack_calls(source: str) -> list[int]: + """Line numbers of every ``inspect.stack(...)`` CALL in *source*. + + Structural, never a string search. The cured guard's own docstring names + ``inspect.stack()`` while explaining why it is gone, and a spelling ban + would convict the explanation — the file would have to stop saying what it + learned in order to pass. An ``ast.Call`` whose func is an ``Attribute`` + named ``stack`` on a ``Name`` ``inspect`` is the defect and nothing else is. + """ + lines: list[int] = [] + for node in ast.walk(ast.parse(source)): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "stack" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "inspect" + ): + lines.append(node.lineno) + return sorted(lines) + + +def _banned_sites() -> tuple[list[str], int]: + """Every ``inspect.stack()`` call under the branch, and the files parsed.""" + offenders: list[str] = [] + scanned = 0 + for py in sorted(BRANCH_ROOT.rglob("*.py")): + parts = py.relative_to(BRANCH_ROOT).parts + if any(part in _BAN_SKIP for part in parts): + continue + try: + source = py.read_text(encoding="utf-8") + lines = _inspect_stack_calls(source) + except (SyntaxError, UnicodeDecodeError): + continue + scanned += 1 + offenders.extend(f"{py.relative_to(BRANCH_ROOT).as_posix()}:{line}" for line in lines) + return offenders, scanned + + +class TestNoInspectStackSurvives: + """A behavioural pin cannot reach every branch of the guard. + + @trigger measured this fleet-wide and @canary reproduced it: the DELETED + second ``inspect.stack()`` walk lived in the guard's ``caller_file is None`` + branch, which no import-shaped world can enter — ``apps/__init__`` always + supplies a real-file frame. Restoring that walk leaves every behavioural + test green (canary: 30 of 31 passed, only their AST ban died). So the + unreachable branch needs a structural pin or it is not pinned at all. + """ + + def test_the_guard_contains_no_inspect_stack_call(self) -> None: + assert _inspect_stack_calls(GUARD.read_text(encoding="utf-8")) == [], ( + "apps/handlers/__init__.py calls inspect.stack() — it needs a readable cwd " + "on Windows before any of the guard's own code runs" + ) + + def test_no_module_in_the_tree_calls_inspect_stack(self) -> None: + offenders, _ = _banned_sites() + assert offenders == [], "inspect.stack() call(s) — walk frames with sys._getframe instead: " + ", ".join( + offenders + ) + + def test_the_ban_actually_parsed_the_tree(self) -> None: + """A walk that visits nothing reports clean. Refuse to call that a pass.""" + _, scanned = _banned_sites() + assert scanned > 40, f"ban only parsed {scanned} modules -- it is blind, not clean" + + def test_a_planted_call_is_convicted_at_its_line(self) -> None: + """Positive control through the REAL matcher, not a re-implementation.""" + assert _inspect_stack_calls("import inspect\nx = 1\ny = inspect.stack()\n") == [3] + + def test_a_docstring_naming_inspect_stack_is_not_a_call(self) -> None: + """The cured guard's own docstring must survive its own ban.""" + assert _inspect_stack_calls('"""Walks frames rather than inspect.stack()."""\n') == [] + assert _inspect_stack_calls("# inspect.stack() is the defect\nx = 1\n") == [] + assert _inspect_stack_calls('s = "inspect.stack()"\n') == [] + + def test_another_libs_stack_is_not_convicted(self) -> None: + """``stack`` is a common verb. Only ``inspect``'s is the defect.""" + assert _inspect_stack_calls("import numpy\nx = numpy.stack([1, 2])\n") == [] + assert _inspect_stack_calls("import traceback\nx = traceback.stack()\n") == [] + assert _inspect_stack_calls("x = self.stack()\n") == [] + + +# --------------------------------------------------------------------------- +# world B -- logging must never take down the caller it logs for +# --------------------------------------------------------------------------- + +#: A caller filename that is NOT on disk. That is the whole point: getsourcefile +#: early-returns for files that exist, so a real path would never reach the +#: unguarded realpath and the pin would prove nothing. Never ```` -- +#: linecache caches stdin and the probe lies green (@devpulse, 2026-08-31). +_PSEUDO_CALLER = "/nonexistent/aipass_probe_caller.py" + +#: Compiled under _PSEUDO_CALLER so these functions ARE the caller frame the +#: name lookup reads. An earlier draft passed a lambda into a compiled helper +#: and the lookup read the lambda's own ```` frame instead -- the pin +#: caught it, which is the only reason this comment exists. +_CALLER_SOURCE = ( + "def call_shared(handler):\n" + " return handler.log_operation('probe', {'x': 1})\n" + "def call_shim(module):\n" + " return module.log_operation('probe', {'x': 1})\n" + "def call_control(fn):\n" + " return fn()\n" +) + +_WORLD_B = """ +import os, tempfile, pathlib + +from aipass.aipass.shared.json_handler import JsonHandler +from aipass.aipass.apps.handlers.json import json_handler as shim + +assert not os.path.exists({caller!r}), "the pseudo-caller must not exist on disk" + + +def _denied(path, *a, **kw): + raise FileNotFoundError(2, "realpath denied", str(path)) + + +# WORLD B: realpath denied, abspath left WORKING. That is the Windows shape -- +# ntpath.abspath succeeds, so control passes inspect's guarded getabsfile and +# reaches the unguarded os.path.realpath inside getmodule's scanning loop. +os.path.realpath = _denied + +ns = {{}} +exec(compile({source!r}, {caller!r}, "exec"), ns) + +# CONTROL: the pre-cure form, rebuilt and run in this same world. If it does +# not raise, the world is not the defect's world and every pin below is vacuous. +import inspect + + +def _pre_cure(): + stack = inspect.stack() + if len(stack) > 2: + name = pathlib.Path(stack[2].filename).stem + if name and not name.startswith("_"): + return name + return "unknown" + + +try: + ns["call_control"](_pre_cure) + print("CONTROL_SURVIVED") +except FileNotFoundError: + print("CONTROL_RAISED") + +shared_dir = tempfile.mkdtemp() +try: + print("SHARED", ns["call_shared"](JsonHandler(shared_dir))) +except Exception as exc: + print("SHARED_DIED %s: %s" % (type(exc).__name__, exc)) +print("SHARED_NAMES", sorted(os.listdir(shared_dir))) + +shim_dir = tempfile.mkdtemp() +shim.AIPASS_JSON_DIR = pathlib.Path(shim_dir) +try: + print("SHIM", ns["call_shim"](shim)) +except Exception as exc: + print("SHIM_DIED %s: %s" % (type(exc).__name__, exc)) +print("SHIM_NAMES", sorted(os.listdir(shim_dir))) +""" + + +class TestLoggingSurvivesWorldB: + """``log_operation`` resolved the caller name BEFORE its own ``try``. + + So ``inspect.stack()`` raising there escaped every handler beneath it and + logging took down the caller it was logging for -- measured by @canary + through this branch's shim, the same species @drone cured in their tree. + + The caller frame here is a ``compile()``d source whose filename is not on + disk, which is what forces ``getsourcefile`` down into ``getmodule``. It + also keeps a USABLE name in ``co_filename``, so the pins can demand the + audit trail survived: returning ``"unknown"`` for every caller satisfies a + not-crash assertion and destroys the record. + """ + + @staticmethod + def _world() -> str: + return _run(_WORLD_B.format(caller=_PSEUDO_CALLER, source=_CALLER_SOURCE)) + + def test_the_pre_cure_form_dies_in_this_world(self) -> None: + """Positive control. Without it, the pins below could pass blind.""" + out = TestLoggingSurvivesWorldB._world() + assert "CONTROL_RAISED" in out, f"world B did not reach the defect -- instrument is blind:\n{out}" + + def test_the_shared_handler_logs_instead_of_raising(self) -> None: + out = TestLoggingSurvivesWorldB._world() + assert "SHARED True" in out, f"shared json_handler.log_operation failed under world B:\n{out}" + + def test_the_shim_logs_instead_of_raising(self) -> None: + out = TestLoggingSurvivesWorldB._world() + assert "SHIM True" in out, f"the branch shim's log_operation failed under world B:\n{out}" + + def test_the_audit_trail_still_names_the_caller(self) -> None: + """Not-crashing is half the contract. The log must still say WHO.""" + out = TestLoggingSurvivesWorldB._world() + stem = Path(_PSEUDO_CALLER).stem + for label in ("SHARED_NAMES", "SHIM_NAMES"): + line = next((ln for ln in out.splitlines() if ln.startswith(label)), "") + assert f"{stem}_log.json" in line, ( + f"{label} did not record the caller as '{stem}' -- a log that answers " + f"'unknown' for every caller passes a not-crash test and destroys the trail:\n{line}" + ) diff --git a/src/aipass/aipass/tests/test_registry_case_sweep.py b/src/aipass/aipass/tests/test_registry_case_sweep.py new file mode 100644 index 000000000..6d61b292c --- /dev/null +++ b/src/aipass/aipass/tests/test_registry_case_sweep.py @@ -0,0 +1,324 @@ +# =================== AIPass ==================== +# Name: test_registry_case_sweep.py +# Description: Case-insensitive filesystem pins for *_REGISTRY.json discovery +# Version: 1.1.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Every ``*_REGISTRY.json`` walk in this tree must be case-SENSITIVE. + +``Path.glob`` asks the FILESYSTEM to match. On Windows -- and on macOS by +default -- that match is case-insensitive, so ``*_REGISTRY.json`` also matches +``*_registry.json``. The bait ships in every branch: ``flow_json/*_registry.json`` +plan counters and a ``.spawn/.template_registry.json`` (pathlib's ``*`` matches +dotfiles, unlike the ``glob`` module). Measured on CI: ``find_registry()`` +returned ``drone_command_registry.json`` as the fleet trust-anchor candidate. + +A registry is a TRUST ANCHOR -- it decides which installation a caller belongs +to, what project name lands on an identity, and where the delete lane thinks +root is. A plan counter answering that is not a near miss; it is a different +question. + +These pins run RED ON LINUX by emulating the widened match, so no Windows box +is needed to keep them honest. + +1.1.0 -- the CONTROL was the thing that assumed a host. It asserted the raw +glob returns nothing, which is false on NTFS, so it failed on the Windows leg +of ebb8075d: broken on the exact platform the defect lives on. The host is +PROBED now and both outcomes are pinned (see :func:`host_folds_case`). A +``skipif`` was refused for the same reason -- it would retire the control where +it matters most. +""" + +from __future__ import annotations + +import ast +import fnmatch +import re +from pathlib import Path + +import pytest + +from aipass.aipass.shared.registry_discovery import find_registry, registries_in + +BRANCH_ROOT = Path(__file__).resolve().parents[1] + + +# --------------------------------------------------------------------------- +# the emulation +# --------------------------------------------------------------------------- + + +@pytest.fixture +def case_insensitive_fs(monkeypatch: pytest.MonkeyPatch): + """Make ``Path.glob`` behave the way Windows/macOS behave. + + A real case-insensitive filesystem matches ANY case permutation, so this + translates the pattern through ``fnmatch`` and re-matches ``iterdir()`` with + ``re.IGNORECASE``. Valid only for single-component, non-recursive patterns + -- which is every registry walk in this tree. + """ + real_iterdir = Path.iterdir + + def widened(self: Path, pattern: str, *args, **kwargs): + rx = re.compile(fnmatch.translate(pattern), re.IGNORECASE) + try: + entries = list(real_iterdir(self)) + except (OSError, ValueError): + return iter(()) + return iter(sorted(p for p in entries if rx.match(p.name))) + + monkeypatch.setattr(Path, "glob", widened) + return widened + + +def host_folds_case(tmp_path: Path) -> bool: + """Does THIS filesystem fold case? Asked in the DEFECT'S OWN DIRECTION. + + Writes a LOWERCASE name and globs the UPPERCASE pattern: an uppercase + pattern reaching a lowercase file is exactly the direction the defect + travels. The reverse direction is a different question and a filesystem is + entitled to answer it differently, so asking it backwards would measure + something this file does not care about (@ai_mail's direction lesson, + 2026-08-31). + + A PROBE, never a ``skipif``: the host answers and BOTH answers are pinned. + A skip would retire the control on the very platform the defect lives on. + + The probe writes into its own subdirectory, so it can never join the decoy + set of the world under test. + """ + probe = tmp_path / "_case_probe" + probe.mkdir(exist_ok=True) + (probe / "probe_registry.json").write_text("{}", encoding="utf-8") + return [path.name for path in probe.glob("*_REGISTRY.json")] == ["probe_registry.json"] + + +def _project(tmp_path: Path) -> tuple[Path, Path]: + """A project root with the real anchor, and a DECOY one level down. + + The decoy is placed so that a widened match does not merely add a wrong + entry to a list -- it changes the ANSWER, because the walk meets ``sub`` + before it meets ``root``. Every assertion below is about where discovery + POINTS, never about set membership. + + Every decoy carries a DISTINCT STEM (``flow``, ``.template``), never a case + twin of ``AIPASS``. Two names differing only by case CANNOT COEXIST on a + folding filesystem: the second write lands in the first file while the + directory keeps the original spelling, so a case-twin decoy would silently + overwrite the real anchor's content and build a DIFFERENT WORLD on Windows + than on Linux (@memory's coexistence fact, 2026-08-31). + """ + root = tmp_path / "root" + deep = root / "sub" / "deep" + deep.mkdir(parents=True) + (root / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") + (root / "sub" / "flow_registry.json").write_text("{}", encoding="utf-8") + (root / "sub" / ".template_registry.json").write_text("{}", encoding="utf-8") + return root, deep + + +# --------------------------------------------------------------------------- +# positive control -- the emulation must genuinely widen +# --------------------------------------------------------------------------- + + +#: The two lowercase baits ``_project`` plants one level below the real anchor. +DECOY_NAMES = {"flow_registry.json", ".template_registry.json"} + + +class TestTheEmulationIsNotBlind: + """A blinded fixture reports green identically to a cure. Prove it bites.""" + + def test_the_raw_glob_answers_exactly_as_the_host_folds(self, tmp_path: Path) -> None: + """The control, pinned in BOTH worlds rather than assuming one. + + This assertion used to read ``== []`` unconditionally. It failed on the + Windows CI leg of ebb8075d -- correctly: NTFS folds case, so the raw + glob really does return the decoys there, and a control that asserts + otherwise is broken on the exact host the defect lives on. + + Where the host folds, the emulation is redundant rather than absent: + the real filesystem already supplies the widened match, so every pin + below is exercised against the genuine article. Where it does not + fold, the emulation is the only thing that widens -- which is what the + companion test proves. + """ + root, _ = _project(tmp_path) + found = {path.name for path in (root / "sub").glob("*_REGISTRY.json")} + if host_folds_case(tmp_path): + assert found == DECOY_NAMES, ( + f"host folds case, so the raw glob must reach the lowercase decoys -- got {found}" + ) + else: + assert found == set(), f"host does NOT fold case, so the raw glob must reach nothing -- got {found}" + + def test_with_emulation_the_lowercase_decoy_IS_matched(self, tmp_path: Path, case_insensitive_fs) -> None: + """If this goes green-by-accident the whole file proves nothing.""" + root, _ = _project(tmp_path) + names = {p.name for p in (root / "sub").glob("*_REGISTRY.json")} + assert names == DECOY_NAMES, f"emulation did not widen -- these pins would pass blind: {names}" + + def test_the_probe_reads_the_world_not_a_platform_string(self, tmp_path: Path, case_insensitive_fs) -> None: + """The probe must be an experiment, not ``sys.platform`` in disguise. + + It is allowed to disagree with the platform -- a case-sensitive volume + can be mounted on Windows and a folding one on Linux -- so a probe that + answered from ``sys.platform`` would be wrong exactly where it matters + and no case-sensitive host could ever catch it. + + Running it inside the folding EMULATION is what makes that catchable + here: a platform string still says False on this Linux runner, while a + probe that genuinely globs says True. The opposite mutant (a probe + hardwired to True) is killed by the control above, which then demands + decoys the raw glob does not return. + """ + assert host_folds_case(tmp_path) is True, ( + "the probe did not see a folding world it was standing in -- it is not measuring the filesystem" + ) + + +# --------------------------------------------------------------------------- +# the reader +# --------------------------------------------------------------------------- + + +class TestRegistriesIn: + def test_rejects_lowercase_even_on_a_case_insensitive_filesystem(self, tmp_path: Path, case_insensitive_fs) -> None: + root, _ = _project(tmp_path) + assert registries_in(root / "sub") == [] + + def test_still_finds_the_real_anchor(self, tmp_path: Path, case_insensitive_fs) -> None: + root, _ = _project(tmp_path) + assert [p.name for p in registries_in(root)] == ["AIPASS_REGISTRY.json"] + + def test_suffix_not_stem_external_projects_name_registries_after_themselves( + self, tmp_path: Path, case_insensitive_fs + ) -> None: + """``VERA-STUDIO_REGISTRY.json`` is valid; nothing promises an uppercase stem.""" + tmp_path.joinpath("Vera-Studio_REGISTRY.json").write_text("{}", encoding="utf-8") + assert [p.name for p in registries_in(tmp_path)] == ["Vera-Studio_REGISTRY.json"] + + def test_unreadable_directory_is_empty_not_an_exception(self, tmp_path: Path) -> None: + assert registries_in(tmp_path / "does_not_exist") == [] + + +# --------------------------------------------------------------------------- +# the behaviour that actually matters +# --------------------------------------------------------------------------- + + +class TestFindRegistryUnderCaseInsensitivity: + def test_the_decoy_does_not_become_the_trust_anchor(self, tmp_path: Path, case_insensitive_fs) -> None: + """The assertion is WHERE discovery points, not what a list contains. + + Widened matching makes ``root/sub/flow_registry.json`` -- a plan + counter -- answer the question "where is this project rooted?". The + project root would silently become ``root/sub``. + """ + root, deep = _project(tmp_path) + found = find_registry(start_path=deep) + assert found is not None + assert found == root / "AIPASS_REGISTRY.json" + assert found.parent == root, f"project root resolved to {found.parent}, not {root}" + + def test_a_directory_holding_only_counters_is_registry_free(self, tmp_path: Path, case_insensitive_fs) -> None: + """No anchor anywhere -- absence, not a counter dressed as an anchor.""" + only = tmp_path / "counters" + only.mkdir() + (only / "fplan_registry.json").write_text("{}", encoding="utf-8") + (only / ".template_registry.json").write_text("{}", encoding="utf-8") + assert find_registry(start_path=only, package_root=str(only)) is None + + def test_package_root_walk_is_filtered_too(self, tmp_path: Path, case_insensitive_fs) -> None: + """Priority 3 is a second walk -- it needs the same filter as priority 2.""" + root, deep = _project(tmp_path) + isolated = tmp_path / "isolated" + isolated.mkdir() + assert find_registry(start_path=isolated, package_root=str(deep)) == (root / "AIPASS_REGISTRY.json") + + +# --------------------------------------------------------------------------- +# the structural pin -- an eighth private copy must be impossible to write +# --------------------------------------------------------------------------- + + +def _private_registry_globs(root: Path) -> tuple[list[str], int]: + """Every direct ``glob("*_REGISTRY.json")`` under *root*, and the file count. + + Returns the count too so a caller can prove the walk actually WALKED -- + see ``TestNoPrivateRegistryGlobSurvives``. + """ + offenders: list[str] = [] + scanned = 0 + for py in sorted(root.rglob("*.py")): + rel = py.relative_to(root).as_posix() + if rel.startswith((".backup/", ".archive/")): + continue + if rel in {"tests/test_registry_case_sweep.py", "shared/registry_discovery.py"}: + continue # the pin itself, and the one sanctioned implementation + try: + tree = ast.parse(py.read_text(encoding="utf-8")) + except (SyntaxError, UnicodeDecodeError): + continue + scanned += 1 + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + if node.func.attr not in {"glob", "rglob"}: + continue + for arg in node.args: + if ( + isinstance(arg, ast.Constant) + and isinstance(arg.value, str) + and arg.value.endswith("_REGISTRY.json") + ): + offenders.append(f"{rel}:{node.lineno}") + return offenders, scanned + + +class TestNoPrivateRegistryGlobSurvives: + """One reader, called by every walk. + + The eighth private copy of ``glob("*_REGISTRY.json")`` is how a fix lands on + some of N identical paths. This pin names every offender by file and line + so a new one cannot be added quietly. + """ + + def test_no_module_globs_the_registry_pattern_directly(self) -> None: + offenders, scanned = _private_registry_globs(BRANCH_ROOT) + assert offenders == [], "private *_REGISTRY.json glob(s) -- call shared registries_in() instead: " + ", ".join( + offenders + ) + + def test_the_walk_actually_walked(self) -> None: + """A scan that visits nothing reports clean. Refuse to call that a pass. + + Found by a mutant: blinding the skip-list to ``if True`` made the walk + visit zero files and every assertion above still passed. A positive + control that exercises a COPY of the logic cannot catch that -- only one + that measures the instrument can. + """ + _, scanned = _private_registry_globs(BRANCH_ROOT) + assert scanned > 50, f"walk only parsed {scanned} modules -- it is blind, not clean" + + def test_the_same_walk_convicts_a_planted_offender(self, tmp_path: Path) -> None: + """Positive control through the REAL function, not a re-implementation.""" + (tmp_path / "clean.py").write_text("x = 1\n", encoding="utf-8") + (tmp_path / "guilty.py").write_text( + 'from pathlib import Path\nx = Path(".").glob("*_REGISTRY.json")\n', + encoding="utf-8", + ) + offenders, scanned = _private_registry_globs(tmp_path) + assert offenders == ["guilty.py:2"] + assert scanned == 2 + + def test_the_walk_also_catches_rglob(self, tmp_path: Path) -> None: + """``rglob`` is the same defect one keystroke away.""" + (tmp_path / "guilty.py").write_text( + 'from pathlib import Path\nx = Path(".").rglob("*_REGISTRY.json")\n', + encoding="utf-8", + ) + offenders, _ = _private_registry_globs(tmp_path) + assert offenders == ["guilty.py:2"] diff --git a/src/aipass/aipass/tests/test_structure_scan.py b/src/aipass/aipass/tests/test_structure_scan.py index a1c58bacd..2ad78613b 100644 --- a/src/aipass/aipass/tests/test_structure_scan.py +++ b/src/aipass/aipass/tests/test_structure_scan.py @@ -9,6 +9,7 @@ """Tests for structure scanner handler — agent detection, placement, pollution, registry.""" import json +import pytest from pathlib import Path from unittest.mock import patch @@ -375,14 +376,65 @@ def test_finds_registry(self, tmp_path: Path) -> None: assert result is not None assert result.name == "AIPASS_REGISTRY.json" - def test_fallback_when_missing(self, tmp_path: Path) -> None: - """Shared find_registry returns fallback when no registry in isolated dir.""" + def test_absence_returns_none_not_a_cwd_guess(self, tmp_path: Path) -> None: + """Absence is reported as None — never as a path the caller might trust. + + The old last resort returned ``Path.cwd() / "AIPASS_REGISTRY.json"``: + a guess about where the caller happens to stand, dressed as a fact + about the machine. A consumer that skips the ``.exists()`` guard -- + or a consumer like @spawn's ``load_registry``, which mints a fresh + ``metadata.id`` for a path it is handed -- acts on that guess. + """ from aipass.aipass.shared.registry_discovery import find_registry isolated = tmp_path / "no_registry" isolated.mkdir() - result = find_registry(start_path=isolated) - assert result.parent != isolated or not result.exists() + assert find_registry(start_path=isolated) is None + + def test_absence_is_none_even_when_cwd_has_a_registry( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The mutant that matters: cwd's registry must not answer for start_path. + + This is the exact defect. The caller asked about ``isolated``; a cwd + that happens to hold a registry made the old code answer with *that* + file, discarding the question. Restoring the fallback makes this red. + """ + from aipass.aipass.shared.registry_discovery import find_registry + + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + (elsewhere / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") + monkeypatch.chdir(elsewhere) + + isolated = tmp_path / "no_registry" + isolated.mkdir() + assert find_registry(start_path=isolated) is None + + def test_package_root_fallback_still_answers(self, tmp_path: Path) -> None: + """None is the LAST resort — the package_root walk still resolves first.""" + from aipass.aipass.shared.registry_discovery import find_registry + + root = tmp_path / "proj" + (root / "pkg" / "deep").mkdir(parents=True) + (root / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") + + isolated = tmp_path / "no_registry" + isolated.mkdir() + found = find_registry(start_path=isolated, package_root=str(root / "pkg" / "deep")) + assert found == root / "AIPASS_REGISTRY.json" + + def test_env_var_still_wins_and_need_not_exist(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """AIPASS_REGISTRY is an explicit instruction, not a discovery result. + + An operator who names a path is not guessing, so this one path is + returned unchecked -- and it stays a Path, never None. + """ + from aipass.aipass.shared.registry_discovery import find_registry + + target = tmp_path / "declared" / "AIPASS_REGISTRY.json" + monkeypatch.setenv("AIPASS_REGISTRY", str(target)) + assert find_registry(start_path=tmp_path) == target # ============================================================================= diff --git a/src/aipass/api/README.md b/src/aipass/api/README.md index 1a030358c..775016a9f 100644 --- a/src/aipass/api/README.md +++ b/src/aipass/api/README.md @@ -264,7 +264,8 @@ api/ │ │ ├── integrations_manager.py # Contract dispatch — integrations list/call │ │ ├── registry.py # Driver auto-discovery (load_drivers) │ │ └── host_serve.py # host_api sub-router — serve/--detach, status, stop -│ ├── handlers/ # Business logic (8 packages, 34 files) +│ ├── handlers/ # Business logic (8 packages, 35 files) +│ │ ├── module_root.py # module_file() — the one guarded __file__ resolve (no import-time cwd read) │ │ ├── auth/env.py, keys.py, secrets.py │ │ ├── config/provider.py │ │ ├── google/auth.py, service_factory.py, retry.py @@ -278,12 +279,53 @@ api/ │ │ └── usage/aggregation.py, cleanup.py, tracking.py │ └── integrations/ # Private driver space (gitignored) │ └── {project}/driver.py -└── tests/ # 1483 test functions across 49 files (1579 collected, parametrised) +└── tests/ # 1634 collected across 50 files (parametrised) └── conformance/settings/ # 39 shared goldens both runtimes must satisfy ``` Three-tier: entry point routes to modules (orchestration), modules delegate to handlers (business logic). Modules auto-discovered from `apps/modules/*.py` via `handle_command()`. `host_serve.py` is a SUB-router: `host_api.py` offers it every `host-api` call first and owns everything it declines, so an unknown subcommand still reaches an error rather than a silent success. + +### No module needs a working directory to be imported (2026-08-31) + +`ntpath.realpath` reads `os.getcwd()` **unconditionally** — not only for relative paths, the way +`posixpath` does — and `Path.resolve()` routes through it. So on Windows every +`Path(__file__).resolve()` *reached at import* is an import-time cwd dependency: a process whose +working directory has been deleted cannot import the module at all. On POSIX the equivalent raise +happens earlier, inside a `try` that `inspect` already owns, which is why this was invisible on +Linux for as long as it existed. + +The discriminator is **reached at import**, not written at module scope, so it is measured by +*running* the imports in a child interpreter, never by grepping for spellings. + +Measured here on 2026-08-31, two denial worlds (A: realpath reads cwd, then cwd is denied — convicts +a raw `resolve()`; B: realpath denied directly, abspath left working — convicts `inspect.stack`): + +| stage | modules red (of 61) | +|---|---| +| before | 61 — the handlers guard masked everything | +| after the guard cure | 49 — one line left: `json_handler.py`'s `API_ROOT` | +| after routing three sites through `module_file()` | 0 | + +Two cures: + +- `apps/handlers/__init__.py` walks frames with `sys._getframe` instead of `inspect.stack()`, + skips pseudo-files and importlib frames *before* any resolve, guards the resolve with a + raw-spelling fallback, and reads the import line with `linecache`. The second `inspect.stack()` + walk in the caller-is-None branch is gone — it was a second copy of the same cwd dependency in + service of a branch that returned either way. +- `apps/handlers/module_root.py` holds `module_file()`, the one guarded spelling. Three + module-level constants route through it (`json/json_handler.py`, `usage/aggregation.py`, + `usage/tracking.py`). Its diagnostics live inside their own protection: the world that reaches + the fallback is the world where prax's logger may be down too, so `sys.stderr` is the last + resort rather than a crash. + +Pinned in `tests/test_dead_cwd_imports.py`. The behavioural pins cannot see the deleted +`inspect.stack()` walk — `apps/__init__` always supplies a real-file frame, so restoring the defect +leaves them green — so an **AST ban** convicts any `inspect.stack` call in the guard file. It is an +AST ban and not a string ban because the guard's own docstring names `inspect.stack` while +explaining the defect, and a spelling ban would convict the explanation. + --- ## Cross-Branch API diff --git a/src/aipass/api/apps/handlers/__init__.py b/src/aipass/api/apps/handlers/__init__.py index 4499b94b4..aa8f4f095 100644 --- a/src/aipass/api/apps/handlers/__init__.py +++ b/src/aipass/api/apps/handlers/__init__.py @@ -1,26 +1,67 @@ """API handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "api" def _find_real_caller(): - """Walk the stack to find the actual file that triggered this import.""" - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) - - for frame_info in stack: - filename = frame_info.filename - if this_file in str(Path(filename).resolve()): - continue + """Walk the stack to find the actual file that triggered this import. + + Skips this file, importlib internals, and frozen modules. + Returns tuple: (file_path, import_line) or (None, None). + + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on the + Windows CI gate 2026-08-31: inspect.stack() needs a READABLE CWD, and it + needs one before any of this function's own code runs. It builds a FrameInfo + per frame, which calls getsourcefile() -> getmodule() -> os.path.realpath(); + ntpath.realpath calls os.getcwd() unconditionally on its first lines, before + it even checks whether the path is absolute, and that call site in getmodule + is not inside a try. On POSIX the equivalent raise happens earlier, inside + getabsfile(), where inspect catches it — which is why this was invisible on + Linux for as long as it existed. A frame's co_filename is already a string + in memory; reading it touches nothing. + """ + # Path.resolve() reaches the same ntpath.realpath, so this is guarded too — + # __file__ is already absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back + continue + + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing every consumer's import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename + + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() - return str(Path(filename).resolve()), import_line + + # linecache is what inspect used for code_context; called directly it + # reads one named file and returns "" rather than raising when it cannot. + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None + + return resolved, import_line + return None, None @@ -49,10 +90,11 @@ def _guard_branch_access(): _guard_logger.info(f"[GUARD] import_line = {import_line}") if caller_file is None: - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return # Allow command-line Python through + # No caller outside this file: an interactive session, a -c script, or an + # importlib-only stack. All three are allowed. This used to walk + # inspect.stack() again looking for / and then return + # either way — a second copy of the cwd dependency above, in service of a + # branch that could not change the answer. return if f"/{MY_BRANCH}/" in caller_file.replace("\\", "/"): diff --git a/src/aipass/api/apps/handlers/json/json_handler.py b/src/aipass/api/apps/handlers/json/json_handler.py index f6a85317a..83499e7c1 100755 --- a/src/aipass/api/apps/handlers/json/json_handler.py +++ b/src/aipass/api/apps/handlers/json/json_handler.py @@ -36,11 +36,13 @@ # Logging from aipass.prax import logger +from aipass.api.apps.handlers.module_root import module_file + # Infrastructure # Constants — package-relative paths # Navigate: json_handler.py -> json/ -> handlers/ -> apps/ -> api/ -API_ROOT = Path(__file__).resolve().parent.parent.parent.parent +API_ROOT = module_file(__file__).parent.parent.parent.parent API_JSON_DIR = API_ROOT / "api_json" # One lock per document, handed out on demand. Every module in this branch logs diff --git a/src/aipass/api/apps/handlers/module_root.py b/src/aipass/api/apps/handlers/module_root.py new file mode 100644 index 000000000..b21b2ef3b --- /dev/null +++ b/src/aipass/api/apps/handlers/module_root.py @@ -0,0 +1,102 @@ +# =================== AIPass ==================== +# Name: module_root.py +# Description: Resolve a module's __file__ without an import-time cwd read +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""One guarded spelling for module-level __file__ resolution. + +ntpath.realpath reads os.getcwd() UNCONDITIONALLY - not only for relative +paths, the way posixpath does - and Path.resolve() routes through it +(measured on the Windows CI gate 2026-08-31, @memory's finding). So on +Windows every module-level Path(__file__).resolve() is an import-time +working-directory dependency: a process whose cwd was deleted cannot import +the module. + +MEASURED IN THIS BRANCH, 2026-08-31: with the handlers guard cured, 49 of +api's 61 modules still died on import in both denial worlds, and the +traceback named ONE line - json_handler.py:43, the API_ROOT constant. Nearly +every module in this tree imports json_handler, so a single unguarded +resolve was the second mask layer. Three module-level sites route through +module_file() here rather than each growing its own try/except. + +Shape mirrors @memory's repo_root.module_file - the ratified fleet cure - +sized to this branch: api's repo_root() derives from drone's registry path +and never walks up to a Path.cwd() fallback, so only the resolve guard +lives here. +""" + +import sys +from pathlib import Path + +from aipass.prax.apps.modules.logger import get_system_logger + +logger = get_system_logger() + +MODULE_NAME = "module_root" + + +def _record_unresolved(path: Path, exc: OSError) -> None: + """Record the fallback; module_file wraps this call in its own protection. + + Runs at module import time on every caller. The logger line is the primary + diagnostic and may itself raise in the world that gets here - that raise + belongs to module_file's wrapper, which owns the promise that no + diagnostic becomes an import crash. + + Args: + path: The __file__ that could not be resolved. + exc: The OSError resolve() raised. + """ + # debug, not warning: in the world that reaches this line every module + # import takes it, and identical warnings describe one condition - + # reported once, loudly, by whichever lane actually fails on it. + logger.debug(f"[{MODULE_NAME}] Cannot resolve {path} ({type(exc).__name__}) — using its absolute spelling") + try: + from aipass.api.apps.handlers.json import json_handler + + json_handler.log_operation( + "module_file_unresolved", + {"path": str(path), "error": f"{type(exc).__name__}: {exc}"}, + module_name=MODULE_NAME, + ) + except Exception as inner: # noqa: BLE001 - an audit line must never take an import down + logger.debug(f"[{MODULE_NAME}] fallback not recorded: {type(inner).__name__}: {inner}") + + +def module_file(file: str) -> Path: + """A module's own path, absolute, resolved when the filesystem allows. + + .resolve() is still attempted, because normalising symlinks is why the + call is there and it succeeds on every healthy machine. The fallback is + only reached in the world where the alternative is a dead import, and it + is sound: __file__ has been absolute since Python 3.9, so the return is + the right file either way - just spelled through the symlink rather than + past it. + + Args: + file: A module's __file__. + + Returns: + The module's path, resolved if the filesystem could be asked. + """ + path = Path(file) + try: + return path.resolve() + except OSError as exc: + # The diagnostics live inside their own protection (@daemon's finding, + # ratified fleet-wide): the world that reaches this line is exactly the + # world where the logger may be down too - prax's logger construction + # reads the cwd. A crashing diagnostic here would BECOME the import + # crash this module exists to prevent. + try: + _record_unresolved(path, exc) + except Exception as inner: # noqa: BLE001 - last resort below, never a re-raise + # stderr is the one channel left that asks nothing of the + # filesystem; not silent, just not the usual instrument. + sys.stderr.write( + f"[{MODULE_NAME}] Cannot resolve {path} and cannot record it ({type(inner).__name__}: {inner})\n" + ) + return path diff --git a/src/aipass/api/apps/handlers/usage/aggregation.py b/src/aipass/api/apps/handlers/usage/aggregation.py index 7b95f7007..508bd8a38 100644 --- a/src/aipass/api/apps/handlers/usage/aggregation.py +++ b/src/aipass/api/apps/handlers/usage/aggregation.py @@ -18,8 +18,6 @@ Functions: get_caller_usage(), get_session_summary() """ -from pathlib import Path - # Standard library imports from typing import Dict, Any, Optional @@ -29,6 +27,8 @@ # Logging from aipass.prax import logger +from aipass.api.apps.handlers.module_root import module_file + # JSON handler from aipass.api.apps.handlers.json import json_handler @@ -40,7 +40,7 @@ MODULE_NAME = "aggregation" DATA_FILE = "usage_tracker_data.json" # Standard 3-file pattern # Navigate: aggregation.py -> usage/ -> handlers/ -> apps/ -> api/ -API_JSON_DIR = Path(__file__).resolve().parent.parent.parent.parent / "api_json" +API_JSON_DIR = module_file(__file__).parent.parent.parent.parent / "api_json" # ============================================= diff --git a/src/aipass/api/apps/handlers/usage/tracking.py b/src/aipass/api/apps/handlers/usage/tracking.py index 5e16bc405..db93759a6 100644 --- a/src/aipass/api/apps/handlers/usage/tracking.py +++ b/src/aipass/api/apps/handlers/usage/tracking.py @@ -18,8 +18,6 @@ Functions: track_usage(), get_generation_metrics(), store_usage_data() """ -from pathlib import Path - # Standard library imports import json import requests @@ -30,6 +28,8 @@ # Logging from aipass.prax import logger +from aipass.api.apps.handlers.module_root import module_file + # JSON handler from aipass.api.apps.handlers.json import json_handler @@ -40,7 +40,7 @@ MODULE_NAME = "tracking" DATA_FILE = "usage_tracker_data.json" # Standard 3-file pattern # Navigate: tracking.py -> usage/ -> handlers/ -> apps/ -> api/ -API_JSON_DIR = Path(__file__).resolve().parent.parent.parent.parent / "api_json" +API_JSON_DIR = module_file(__file__).parent.parent.parent.parent / "api_json" # OpenRouter API configuration OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" diff --git a/src/aipass/api/tests/test_dead_cwd_imports.py b/src/aipass/api/tests/test_dead_cwd_imports.py new file mode 100644 index 000000000..7c13d4e7c --- /dev/null +++ b/src/aipass/api/tests/test_dead_cwd_imports.py @@ -0,0 +1,349 @@ +# =================== AIPass ==================== +# Name: test_dead_cwd_imports.py +# Description: Windows dead-cwd import defect - every api module imports without a readable cwd +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""No api module may read the working directory to be IMPORTED. + +THE DEFECT (measured on the Windows CI gate 2026-08-31, @memory's finding): +ntpath.realpath calls os.getcwd() UNCONDITIONALLY - not only for relative +paths, the way posixpath does - and Path.resolve() routes through it. So on +Windows every Path(__file__).resolve() REACHED AT IMPORT is an import-time +working-directory dependency: a process whose cwd has been deleted cannot +import the module at all. On POSIX the equivalent raise happens earlier and +inside a try inspect already owns, which is why this was invisible on Linux +for as long as it existed. + +The discriminator is REACHED AT IMPORT, not written at module scope, so these +pins RUN the imports rather than grepping for spellings. A grep finds +spellings; only the run finds reachability. + +TWO WORLDS, because one cannot convict both species: + + World A - wrap os.path.realpath so it reads os.getcwd() first (emulating + ntpath), then deny os.getcwd. Convicts a raw resolve(). It CANNOT convict + inspect.stack: denying getcwd kills abspath, so getmodule dies at + getabsfile INSIDE inspect's own except and stack() completes green for the + wrong reason (@daemon measured this). + + World B - deny os.path.realpath directly and leave abspath working. + Convicts inspect.stack via getmodule's unguarded realpath at + inspect.py:1009. + +MEASURED IN THIS BRANCH: 61/61 modules red in both worlds before the cure. +The handlers guard masked everything (apps/__init__ does `from . import +handlers`, so every module died there first); curing it left 49/61, and the +traceback named ONE remaining line - json_handler.py:43's API_ROOT constant, +which nearly every module in this tree imports. 0/61 after both cures. +""" + +import ast +import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +# api/tests/ -> api/ +BRANCH_ROOT = Path(__file__).resolve().parent.parent +REPO_ROOT = BRANCH_ROOT.parent.parent.parent +GUARD_FILE = BRANCH_ROOT / "apps" / "handlers" / "__init__.py" + +# Cross-branch deps api imports at module scope. Preloaded in the HEALTHY world +# because their cure is their own build - this file measures api only. +# aipass.trigger is deliberately NOT preloaded: usage_tracker imports it lazily +# inside a function, so it is never reached at import, and a preload is a claim +# you stop testing (@flow's rule). +PRELOAD = [ + "aipass.prax", + "aipass.cli", + "aipass.drone", + "aipass.ai_mail", + "aipass.skills.lib.screen_lock", + "aipass.hooks.apps.sound", +] + + +def _api_modules() -> list[str]: + """Every importable api module, as dotted names.""" + names = [] + for path in sorted((BRANCH_ROOT / "apps").rglob("*.py")): + if ".archive" in path.parts or "parked" in path.parts: + continue + rel = path.relative_to(BRANCH_ROOT).with_suffix("") + parts = list(rel.parts) + if parts[-1] == "__init__": + parts.pop() + names.append(".".join(["aipass", "api", *parts])) + return names + + +_PROBE = """ +import importlib, json, os, sys + +WORLD = os.environ["PROBE_WORLD"] +MODULES = json.loads(os.environ["PROBE_MODULES"]) +PRELOAD = json.loads(os.environ["PROBE_PRELOAD"]) + +preload_failed = {} +for name in PRELOAD: + try: + importlib.import_module(name) + except Exception as e: + preload_failed[name] = f"{type(e).__name__}: {e}" + +_real_realpath = os.path.realpath + +if WORLD == "A": + def realpath_reads_cwd(p, *a, **k): + os.getcwd() + return _real_realpath(p, *a, **k) + os.path.realpath = realpath_reads_cwd + def dead_getcwd(*a, **k): + raise FileNotFoundError(2, "No such file or directory") + os.getcwd = dead_getcwd +elif WORLD == "B": + def dead_realpath(*a, **k): + raise OSError(2, "No such file or directory") + os.path.realpath = dead_realpath + +# Positive control, and the negative control FOR it: the injection must deny +# the call the defect actually makes, and must NOT be firing on everything. +from pathlib import Path as _P +if WORLD == "A": + try: + _P(".").resolve() + control_live, control_no = False, None + except OSError: + control_live = True + try: + os.path.basename("/tmp/x") + control_no = True + except OSError: + control_no = False +else: + try: + os.path.realpath("/tmp/x") + control_live, control_no = False, None + except OSError: + control_live = True + try: + os.path.abspath("/tmp/x") + control_no = True + except OSError: + control_no = False + +def _purge(): + for n in [m for m in sys.modules if m == "aipass.api" or m.startswith("aipass.api.")]: + del sys.modules[n] + +results = {} +for mod in MODULES: + _purge() + try: + importlib.import_module(mod) + results[mod] = "OK" + except Exception as e: + results[mod] = f"{type(e).__name__}: {str(e)[:160]}" + +print("@@PROBE@@" + json.dumps({ + "world": WORLD, "control_live": control_live, "control_can_say_no": control_no, + "preload_failed": preload_failed, "results": results, +})) +""" + + +def _run_probe(world: str, modules: list[str]) -> dict: + """Run the probe in a child interpreter under a STRING pseudo-frame. + + compile() with an angle-bracket filename, never stdin: linecache caches + stdin and a stdin-fed probe reports green for the wrong reason (@hooks' + finding). + """ + env = dict(os.environ) + env["PROBE_WORLD"] = world + env["PROBE_MODULES"] = json.dumps(modules) + env["PROBE_PRELOAD"] = json.dumps(PRELOAD) + runner = f"import sys\nsrc = {_PROBE!r}\nexec(compile(src, '', 'exec'))\n" + proc = subprocess.run( + [sys.executable, "-c", runner], + cwd=str(REPO_ROOT), + env=env, + capture_output=True, + text=True, + timeout=300, + ) + for line in proc.stdout.splitlines(): + if line.startswith("@@PROBE@@"): + return json.loads(line[len("@@PROBE@@") :]) + raise AssertionError(f"probe produced no verdict\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}") + + +class TestEveryModuleImportsWithoutAReadableCwd: + """The import fence: no api module may need a cwd to be imported.""" + + @pytest.mark.parametrize("world", ["A", "B"]) + def test_the_injection_is_live_and_can_still_say_no(self, world: str) -> None: + """The positive control fires, and it is not firing indiscriminately. + + A CONTROL_LIVE probe that cannot report its own defeat turns every pin + below it vacuously green (@spawn's finding, relayed by @memory). + """ + report = _run_probe(world, []) + assert report["control_live"] is True, f"world {world}: the denial never fired" + assert report["control_can_say_no"] is True, ( + f"world {world}: the denial is indiscriminate - it fires on calls the defect never makes" + ) + + @pytest.mark.parametrize("world", ["A", "B"]) + def test_no_api_module_dies_on_import(self, world: str) -> None: + """Every api module imports in a process whose cwd cannot be read.""" + modules = _api_modules() + assert modules, "no api modules discovered - the probe would pass vacuously" + report = _run_probe(world, modules) + assert report["control_live"] is True, "injection not live - result says nothing" + assert not report["preload_failed"], f"preload broke in the healthy world: {report['preload_failed']}" + dead = {k: v for k, v in report["results"].items() if v != "OK"} + assert not dead, ( + f"world {world}: {len(dead)} of {len(modules)} api modules need a cwd to import:\n" + + "\n".join(f" {k} -> {v}" for k, v in sorted(dead.items())) + ) + + +# -------------------------------------------------------------------------- +# The pin-shape hole: the deleted inspect.stack() walk is UNREACHABLE from any +# import-shaped test (apps/__init__ always supplies a real-file frame), so +# restoring the defect leaves every behavioural pin green (@trigger measured +# this). Only a structural ban convicts it. +# +# An AST ban, never a string ban: the guard's docstring NAMES inspect.stack +# while explaining the defect, and a spelling ban convicts the explanation. +# -------------------------------------------------------------------------- + + +def _inspect_stack_calls(source: str) -> list[int]: + """Line numbers of every `inspect.stack(...)` CALL in *source*.""" + found = [] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.Call): + continue + func = node.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "stack" + and isinstance(func.value, ast.Name) + and func.value.id == "inspect" + ): + found.append(node.lineno) + return found + + +class TestTheGuardNeverWalksInspectStack: + """inspect.stack() may not return to the handlers guard.""" + + def test_the_guard_file_calls_inspect_stack_nowhere(self) -> None: + hits = _inspect_stack_calls(GUARD_FILE.read_text(encoding="utf-8")) + assert hits == [], ( + f"{GUARD_FILE.name} calls inspect.stack() at line(s) {hits} - " + "it needs a readable cwd before any of the guard's own code runs" + ) + + def test_the_ban_convicts_a_real_call(self) -> None: + """Positive control: the checker is not simply returning [].""" + assert _inspect_stack_calls("import inspect\nx = inspect.stack()\n") == [2] + + def test_the_ban_ignores_a_docstring_that_names_it(self) -> None: + """Negative control: the guard's own explanation must not convict it.""" + source = '"""Walks frames rather than inspect.stack(), which reads the cwd."""\n' + assert _inspect_stack_calls(source) == [] + + def test_the_ban_ignores_an_unrelated_stack_attribute(self) -> None: + """Negative control: numpy.stack is a different function entirely.""" + assert _inspect_stack_calls("import numpy\ny = numpy.stack([1, 2])\n") == [] + + +# -------------------------------------------------------------------------- +# The runtime species: json_handler's caller auto-detection runs on the +# log_operation hot path. It must not crash without a cwd - and it must still +# ANSWER. Returning "unknown" for every caller satisfies a not-crash assertion +# and destroys the audit trail (@flow's pin shape). +# -------------------------------------------------------------------------- + +_CALLER_PROBE = """ +import importlib, json, os, sys + +for name in json.loads(os.environ["PROBE_PRELOAD"]): + importlib.import_module(name) + +sys.path.insert(0, os.environ["PROBE_TMP"]) +jh = importlib.import_module("aipass.api.apps.handlers.json.json_handler") +named = importlib.import_module("deadcwd_named_caller") + +def dead_realpath(*a, **k): + raise OSError(2, "No such file or directory") +os.path.realpath = dead_realpath + +try: + os.path.realpath("/tmp/x") + control_live = False +except OSError: + control_live = True + +try: + answer = named.ask(jh) + err = None +except Exception as e: + answer, err = None, f"{type(e).__name__}: {e}" + +print("@@CALLER@@" + json.dumps({"control_live": control_live, "answer": answer, "error": err})) +""" + + +class TestCallerDetectionStillAnswersWithoutARealpath: + """log_operation's auto-detect must survive world B AND still name the caller.""" + + def test_it_names_the_calling_module_not_unknown(self, tmp_path: Path) -> None: + # A REAL file supplies the caller frame, while the probe's own top-level + # frame is a string pseudo-frame - the shape the defect actually meets. + (tmp_path / "deadcwd_named_caller.py").write_text( + textwrap.dedent( + """ + def ask(jh): + # Mimics log_operation's depth: _get_caller_module_name + # skips [0]=itself, [1]=its caller, [2]=the real caller. + def stand_in_for_log_operation(): + return jh._get_caller_module_name() + return stand_in_for_log_operation() + """ + ), + encoding="utf-8", + ) + env = dict(os.environ) + env["PROBE_PRELOAD"] = json.dumps(PRELOAD) + env["PROBE_TMP"] = str(tmp_path) + runner = f"import sys\nsrc = {_CALLER_PROBE!r}\nexec(compile(src, '', 'exec'))\n" + proc = subprocess.run( + [sys.executable, "-c", runner], + cwd=str(REPO_ROOT), + env=env, + capture_output=True, + text=True, + timeout=300, + ) + verdict = None + for line in proc.stdout.splitlines(): + if line.startswith("@@CALLER@@"): + verdict = json.loads(line[len("@@CALLER@@") :]) + assert verdict is not None, f"probe produced no verdict\n{proc.stdout}\n{proc.stderr}" + assert verdict["control_live"] is True, "realpath denial never fired - result says nothing" + assert verdict["error"] is None, f"caller detection crashed without a realpath: {verdict['error']}" + assert verdict["answer"] == "deadcwd_named_caller", ( + f"caller detection answered {verdict['answer']!r} - a not-crash that names nobody " + "destroys the audit trail it exists to write" + ) diff --git a/src/aipass/backup/README.md b/src/aipass/backup/README.md index 4eca3e827..e57f6b7d6 100644 --- a/src/aipass/backup/README.md +++ b/src/aipass/backup/README.md @@ -50,7 +50,8 @@ apps/ ├── drive/ # Google Drive handlers (auth, upload, tracker, share) ├── ignore/ # .backupignore patterns + whitelist ├── json/ # JSON persistence, atomic writes, ops log - ├── path/ # Backup path building + caller-CWD resolution + ├── path/ # Backup path building, caller-CWD resolution, + │ # and module_paths.py (the safe-resolve helper) ├── project/ # Config, registry, setup (.backup/) ├── report/ # Result formatting ├── scan/ # Directory walking + filtering + the run ceiling @@ -221,6 +222,83 @@ Removing a now-ignored tree from a store is currently a manual `rm -rf` of the c --- +### Fabricated filenames never name the real tree (round 12) + +The fence pins drive the guard by compiling `check()` under a made-up caller +filename. coverage.py records every executed code object BY FILENAME, existing +file or not -- so a fabrication that looks like a real tree file makes the +coverage *report* step exit 1 with `No source for code` while every test passes. +That is what reddened the coverage CI leg on `5bfd5b63`. + +Two rules, both pinned: + +- Every fabricated filename lives under `tmp_path`, outside coverage's `source` + filter. Real-tree adjacency (is `src/aipass/memory` foreign? is a real backup + file kin?) is asserted on `_is_kin`, which is pure and compiles nothing. +- There is exactly ONE `compile()` in the test file, and it refuses a filename + that `abspath`s inside the source tree. `abspath`, not the literal: coverage + resolves a relative name against the cwd at trace time, so a Windows-spelled + literal is inert from the repo root and a minter from the branch directory. + +### Kinship is spelled, not compared raw (round 5) + +The handlers fence asks one question -- is this caller inside my branch? -- and +until 2026-08-31 it asked it with a raw substring test that normalised only ONE +side. On Windows `_BRANCH_ROOT` arrives from `Path` with backslashes while the +caller had just had its backslashes replaced with forward slashes, so the test +could never match: every file in this branch read as FOREIGN and the whole tree +died at the door with backup's own ACCESS DENIED message. + +Both sides now go through `_spell_for_kinship()`. Case is folded only when +`os.name == "nt"` -- folding everywhere would ADMIT a foreign `/tmp/BACKUP` on a +case-sensitive filesystem, which is a wider fence, not a safer one. The guard's +own-frame skip uses the same rule for a sharper reason: if that skip misses, +`__init__.py` becomes the reported caller, is trivially kin, and the real +foreign frame beneath it is never examined. + +## Path Resolution — why nothing here calls `resolve()` at import + +`ntpath.realpath` reads `os.getcwd()` **unconditionally** (posixpath only does so +for relative paths), and `Path.resolve()` routes through it. So on Windows every +`resolve()` *reached at import time* is an import-time crash for a process whose +cwd has been deleted: the module cannot be imported at all. The discriminator is +**reached-at-import**, not written-at-module-scope — a `resolve()` inside a +function that the module calls while importing is just as fatal. + +Every module-level path in this branch therefore goes through one helper, +`handlers/path/module_paths.py`: + +- `module_file(__file__)` — `resolve()` first, so symlinks still collapse + normally; on `OSError` it degrades to `os.path.abspath`, which is the identity + for an already-absolute path and needs no cwd. +- `branch_root(__file__, n)` — the same, then climbs `n` levels. + +The helper is **stdlib-only on purpose**. Importing `@prax` here would put the +logger's own cwd-reading construction onto the path this module exists to +protect, so its diagnostics go to `sys.stderr` — reported once per path, because +in a dead-cwd world *every* resolve fails and one line per call would bury the +real traceback. + +The handlers package guard walks `sys._getframe` rather than `inspect.stack()`. +`inspect.stack()` calls `getmodule` → `getabsfile` → `os.path.realpath` on every +frame with no guard (inspect.py:1009), so it dies before the guard's own +skip-the-pseudo-frame logic is ever consulted. Reading `f_code.co_filename` off +the frame touches no filesystem at all. + +Measured on 2026-08-31 by importing all 57 modules in a child interpreter under +two injections: **57/57 failed to import before the cure, 0/57 after**. +See `tests/test_dead_cwd_imports.py`, which carries an AST ban on +`inspect.stack()` — a behavioural test cannot catch its return, because the +branch that used it is unreachable from any import-shaped pin. + +**Backup destinations are unaffected by all of this.** Every path under +`.backup/` is derived from the caller-supplied `project_root` (see +`handlers/path/builder.py`), never from a module-level resolve and never from +the cwd — so no backup or archive has ever been written to a location derived +from where the caller's shell happened to be standing. + +--- + ## Integration Points ### Depends On diff --git a/src/aipass/backup/apps/handlers/__init__.py b/src/aipass/backup/apps/handlers/__init__.py index 9f57efe1f..37f3888bc 100644 --- a/src/aipass/backup/apps/handlers/__init__.py +++ b/src/aipass/backup/apps/handlers/__init__.py @@ -1,35 +1,107 @@ -"""BACKUP handlers package - Security protected.""" +"""BACKUP handlers package - Security protected. -import inspect +The branch's ONE safe path helper lives in ``path/module_paths.py``; this +package's own module-level paths go through it for the same reason every other +module does -- ``Path.resolve()`` reached at import time is an import-time crash +on Windows for a process whose cwd was deleted. +""" + +import linecache +import os +import sys from pathlib import Path +from .path.module_paths import branch_root, module_file # noqa: F401 (re-exported) + MY_BRANCH = "backup" -_HANDLER_DIR = str(Path(__file__).resolve().parent) + +#: Frames that are not real files on disk. Resolving one needs a cwd, so they +#: are skipped BEFORE anything touches the filesystem. +_PSEUDO_FRAME_PREFIX = "<" +_IMPORT_MACHINERY = "importlib" + +#: Windows spells paths with backslashes and folds case; POSIX does neither. +_IS_WINDOWS = os.name == "nt" + +_HANDLER_DIR = str(module_file(__file__).parent) +_BRANCH_ROOT = str(branch_root(__file__, 2)) + + +def _spell_for_kinship(path: str, *, windows: bool) -> str: + """Spell a path for the kinship test. BOTH sides must go through this. + + Round 5, measured on the windows-setup runner: this guard used to normalise + only the CALLER (``caller_file.replace("\\", "/")``) and compare it against + a ``_BRANCH_ROOT`` that came straight from ``Path`` -- backslashed on + Windows. A forward-slashed caller can never contain a backslashed root, so + every file in the branch read as FOREIGN, the door import raised, and the + whole tree died at the fence with backup's own ACCESS DENIED message. + + Case is folded only on Windows. Folding everywhere would ADMIT a foreign + ``/tmp/BACKUP`` on a case-sensitive filesystem, which is a wider fence, not + a safer one. + """ + spelled = path.replace("\\", "/") + return spelled.lower() if windows else spelled + + +def _is_kin(caller_file: str, branch_root: str, *, windows: bool | None = None) -> bool: + """Is ``caller_file`` inside ``branch_root``? + + Pure, and parametrised on the dialect, so the Windows reading is testable + from Linux -- the defect needs a backslash, not a Windows box. + """ + if windows is None: + windows = _IS_WINDOWS + return _spell_for_kinship(branch_root, windows=windows) in _spell_for_kinship(caller_file, windows=windows) def _find_real_caller(): """Walk the stack to find the actual file that triggered this import. - Skips this file, importlib internals, and frozen modules. + Uses ``sys._getframe`` rather than ``inspect.stack()``. ``inspect.stack()`` + reaches ``os.path.realpath`` unguarded through ``getsourcefile`` -> + ``getmodule`` (3.12: inspect.py:1009 -- ``getabsfile`` uses ``abspath``, so + the route is getmodule's), so it dies on a dead cwd before this function's + own skip logic is ever consulted. Reading ``f_code.co_filename`` + off the frame touches no filesystem at all. + Returns tuple: (file_path, import_line) or (None, None). """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) + this_file = str(module_file(__file__)) - for frame_info in stack: - filename = frame_info.filename + try: + frame = sys._getframe(1) + except ValueError: + return None, None - if this_file in str(Path(filename).resolve()): - continue + while frame is not None: + filename = frame.f_code.co_filename - if filename.startswith("<") or "importlib" in filename: + # Skip Python internals BEFORE touching the filesystem — resolving a + # pseudo-filename like needs a cwd, and a process whose cwd + # was deleted dies here otherwise. + if filename.startswith(_PSEUDO_FRAME_PREFIX) or _IMPORT_MACHINERY in filename: + frame = frame.f_back continue - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + # A relative frame filename also needs a cwd to resolve; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename + + # Same spelling rule as the kinship test, and for a sharper reason: + # if the self-skip MISSES, this file becomes the reported caller, it is + # trivially kin, and the real foreign caller below it is never examined + # -- a case difference would open the fence, not just close it. + if _spell_for_kinship(this_file, windows=_IS_WINDOWS) in _spell_for_kinship(resolved, windows=_IS_WINDOWS): + frame = frame.f_back + continue - return str(Path(filename).resolve()), import_line + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + return resolved, import_line return None, None @@ -53,14 +125,9 @@ def _guard_branch_access(): caller_file, import_line = _find_real_caller() if caller_file is None: - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return return - branch_root = str(Path(_HANDLER_DIR).parents[1]) - if branch_root in caller_file.replace("\\", "/"): + if _is_kin(caller_file, _BRANCH_ROOT): return caller_branch = _extract_branch_name(caller_file) diff --git a/src/aipass/backup/apps/handlers/json/json_handler.py b/src/aipass/backup/apps/handlers/json/json_handler.py index b62fd91c7..3eee076ea 100644 --- a/src/aipass/backup/apps/handlers/json/json_handler.py +++ b/src/aipass/backup/apps/handlers/json/json_handler.py @@ -17,6 +17,8 @@ from aipass.prax import append_jsonl, logger +from ..path.module_paths import branch_root + # os.replace on Windows raises PermissionError while ANY reader holds the # target open (no FILE_SHARE_DELETE on Python's open). Readers hold handles @@ -56,7 +58,7 @@ def log_operation(operation: str, data: dict) -> None: "operation": operation, **data, } - log_file = Path(__file__).resolve().parents[3] / "logs" / "operations.jsonl" + log_file = branch_root(__file__, 3) / "logs" / "operations.jsonl" try: append_jsonl(log_file, entry) except OSError as e: diff --git a/src/aipass/backup/apps/handlers/path/module_paths.py b/src/aipass/backup/apps/handlers/path/module_paths.py new file mode 100644 index 000000000..c53d97d30 --- /dev/null +++ b/src/aipass/backup/apps/handlers/path/module_paths.py @@ -0,0 +1,88 @@ +# =================== AIPass ==================== +# Name: module_paths.py +# Description: Safe module-file path resolution that never requires a live cwd +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""The branch's ONE safe path helper. + +``ntpath.realpath`` reads ``os.getcwd()`` unconditionally -- posixpath only does +so for relative paths -- and ``Path.resolve()`` routes through it. So on Windows +every ``resolve()`` reached at import time is an import-time crash for a process +whose cwd was deleted: the module cannot be imported at all. + +Every module-level path in this branch goes through :func:`module_file` or +:func:`branch_root`, so that world degrades to the raw absolute spelling instead +of killing the import. + +Stdlib only, deliberately: this is imported by the handlers package guard before +anything else exists, and importing @prax here would put the logger's own +cwd-reading construction on the path this module exists to protect. +""" + +import os +import sys +from pathlib import Path + +#: Paths already reported as degraded. A dead cwd makes EVERY resolve fail, and +#: one line per call would bury the real traceback under its own noise. +_REPORTED_DEGRADED: set[str] = set() + + +def module_file(dunder_file: str) -> Path: + """Absolute path of a module file, without requiring a readable cwd. + + ``Path.resolve()`` is tried first so symlinks still collapse normally. When + the cwd is gone the fallback is ``os.path.abspath`` on a path that is + already absolute -- which is the identity, and needs no cwd. + + Args: + dunder_file: A module's ``__file__``. + + Returns: + Absolute Path. Symlinks are resolved when the world allows it. + """ + raw = Path(dunder_file) + try: + return raw.resolve() + except OSError as exc: + _report_degraded(dunder_file, exc) + try: + return Path(os.path.abspath(dunder_file)) + except OSError as abspath_exc: + _report_degraded(f"{dunder_file} (abspath)", abspath_exc) + return raw + + +def branch_root(dunder_file: str, parents: int) -> Path: + """Walk up from a module file to a branch-relative root. + + Args: + dunder_file: A module's ``__file__``. + parents: How many levels up from the file to climb. + + Returns: + Absolute Path to the ancestor directory. + """ + return module_file(dunder_file).parents[parents] + + +def _report_degraded(dunder_file: str, exc: OSError) -> None: + """Announce a degraded resolution once, on a channel that cannot be down. + + The diagnostics live INSIDE their own protection: the world that reaches + this fallback may have taken the logger with it, because @prax builds its + logger by reading the cwd. A stream report is the accepted last resort. + """ + if dunder_file in _REPORTED_DEGRADED: + return + _REPORTED_DEGRADED.add(dunder_file) + try: + sys.stderr.write(f"[backup] cwd unreadable, using raw path for {dunder_file}: {exc}\n") + except OSError: + # stderr itself is gone. There is no third channel to report on, and + # raising here would replace a degraded path with a dead import -- + # exactly the failure this module exists to prevent. + return diff --git a/src/aipass/backup/apps/handlers/project/registry.py b/src/aipass/backup/apps/handlers/project/registry.py index 0a35184ae..ce86556ae 100644 --- a/src/aipass/backup/apps/handlers/project/registry.py +++ b/src/aipass/backup/apps/handlers/project/registry.py @@ -14,9 +14,10 @@ from pathlib import Path +from ..path.module_paths import branch_root from ..json import json_handler -REGISTRY_PATH = Path(__file__).resolve().parents[3] / "backup_json" / "project_registry.json" +REGISTRY_PATH = branch_root(__file__, 3) / "backup_json" / "project_registry.json" def load_project_registry() -> dict: diff --git a/src/aipass/backup/apps/handlers/project/setup.py b/src/aipass/backup/apps/handlers/project/setup.py index c3567a09b..77df32ff6 100644 --- a/src/aipass/backup/apps/handlers/project/setup.py +++ b/src/aipass/backup/apps/handlers/project/setup.py @@ -15,10 +15,11 @@ from datetime import datetime, timezone from pathlib import Path +from ..path.module_paths import branch_root from ..json import json_handler from ..path import builder -_TEMPLATE_PATH = Path(__file__).resolve().parents[3] / "templates" / "backupignore.template" +_TEMPLATE_PATH = branch_root(__file__, 3) / "templates" / "backupignore.template" def _build_backupignore() -> str: diff --git a/src/aipass/backup/apps/handlers/state/backup_timestamps.py b/src/aipass/backup/apps/handlers/state/backup_timestamps.py index 2c7bc2799..1cbae36f8 100644 --- a/src/aipass/backup/apps/handlers/state/backup_timestamps.py +++ b/src/aipass/backup/apps/handlers/state/backup_timestamps.py @@ -12,13 +12,13 @@ import os import tempfile from datetime import datetime -from pathlib import Path from aipass.prax import logger +from ..path.module_paths import branch_root from ..json import json_handler -_BACKUP_ROOT = Path(__file__).resolve().parents[3] +_BACKUP_ROOT = branch_root(__file__, 3) TIMESTAMPS_FILE = _BACKUP_ROOT / "backup_json" / "backup_timestamps.json" MODES = ["snapshot", "versioned", "drive_sync"] diff --git a/src/aipass/backup/tests/test_cli_routing.py b/src/aipass/backup/tests/test_cli_routing.py index af6a0b28e..58c54896c 100644 --- a/src/aipass/backup/tests/test_cli_routing.py +++ b/src/aipass/backup/tests/test_cli_routing.py @@ -12,6 +12,7 @@ import sys import tempfile import types +from contextlib import ExitStack, contextmanager from io import StringIO from pathlib import Path from unittest.mock import MagicMock, patch @@ -194,6 +195,27 @@ def test_print_introspection_exists(self, mod_path: str) -> None: ("versioned", "run_versioned", []), ] +#: Real work that runs AFTER the sentinel and must be neutralised too. +#: +#: Patching the sentinel alone is not enough for 'all': handle_command calls +#: run_snapshot, then run_versioned, then a LIVE run_drive_sync. With only +#: run_snapshot spied, the rest of the production pipeline really executed -- +#: the Drive step authenticated through @api and refreshed the machine's real +#: ~/.secrets/aipass/google_creds.json (mkdir + chmod 0700 + open('w') + +#: chmod 0600 in api/apps/handlers/google/auth.py:261-265). An open(..., 'w') +#: truncates on open, so a failure mid-write could corrupt live credentials on +#: any machine that ran this suite. Found 2026-08-30 by @seedgo's audit-tests +#: lane -- the only write any branch made outside the audit copy. +#: +#: run_drive_sync is imported INSIDE handle_command, so it has to be patched at +#: its source module, not as an attribute of 'all'. +DOWNSTREAM_AFTER_SENTINEL: dict[str, tuple[str, ...]] = { + "all": ( + "aipass.backup.apps.modules.all.run_versioned", + "aipass.backup.apps.modules.drive_sync.run_drive_sync", + ), +} + class TestHelpGateInsideHandleCommand: """handle_command itself must screen help flags, not just the router. @@ -230,12 +252,89 @@ def test_real_invocation_still_dispatches(self, name: str, sentinel: str, extra: mod = self._load(name) args = [str(tmp_path), *extra, "some_file.py"] if extra else [str(tmp_path)] - with patch.object(mod, sentinel) as spy: + with ExitStack() as stack: + spy = stack.enter_context(patch.object(mod, sentinel)) + for target in DOWNSTREAM_AFTER_SENTINEL.get(name, ()): + stack.enter_context(patch(target)) mod.handle_command(mod.PRIMARY_COMMAND, args) spy.assert_called() +#: sys.addaudithook can never be uninstalled, so the hook goes in once at module +#: scope and stays inert unless _secrets_watch() has armed it. +_SECRETS_ROOT = str(Path.home() / ".secrets") +_SECRETS_TOUCHED: list[str] | None = None +_WATCHED_EVENTS = ("open", "os.mkdir", "os.chmod", "os.rename", "os.replace") + + +def _secrets_audit_hook(event: str, args: tuple) -> None: + """Record any touch of real secret storage while the watch is armed.""" + if _SECRETS_TOUCHED is None or event not in _WATCHED_EVENTS: + return + target = str(args[0]) if args else "" + if target.startswith(_SECRETS_ROOT): + _SECRETS_TOUCHED.append(f"{event} {target}") + + +sys.addaudithook(_secrets_audit_hook) + + +@contextmanager +def _secrets_watch(): + """Arm the audit hook and yield the list it records into.""" + global _SECRETS_TOUCHED + _SECRETS_TOUCHED = [] + try: + yield _SECRETS_TOUCHED + finally: + _SECRETS_TOUCHED = None + + +class TestNoWriteEscapesToRealSecrets: + """The 'all' cycle must never reach live credential storage from a test. + + Regression, found 2026-08-30 by @seedgo's audit-tests lane -- the only write + any branch made outside the audit copy. test_real_invocation_still_dispatches + patched run_snapshot only, so handle_command fell straight through to a REAL + run_drive_sync, which authenticated through @api and rewrote + ~/.secrets/aipass/google_creds.json (auth.py:261-265: mkdir, chmod 0700, + open('w'), chmod 0600). open(..., 'w') truncates on open, so a failure + mid-write could corrupt the machine's live Google credentials. + + The audit hook here RECORDS and does not block. Blocking mid-auth pushes the + Google client into its interactive browser flow, which hangs forever -- a pin + that hangs teaches nobody anything. The cost of recording is that a genuine + regression writes the real file once before this test goes red; that is the + status quo it exists to end, not a new risk it introduces. + """ + + def test_all_stops_at_the_patched_doubles(self, tmp_path: Path) -> None: + """Every step after the sentinel is a double, so no real work runs.""" + mod = importlib.import_module("aipass.backup.apps.modules.all") + + with ExitStack() as stack: + snap = stack.enter_context(patch.object(mod, "run_snapshot")) + doubles = [stack.enter_context(patch(target)) for target in DOWNSTREAM_AFTER_SENTINEL["all"]] + mod.handle_command(mod.PRIMARY_COMMAND, [str(tmp_path)]) + + snap.assert_called() + for double in doubles: + double.assert_called() + + def test_no_write_reaches_real_secret_storage(self, tmp_path: Path) -> None: + """Running 'all' touches nothing under ~/.secrets.""" + mod = importlib.import_module("aipass.backup.apps.modules.all") + + with _secrets_watch() as touched, ExitStack() as stack: + stack.enter_context(patch.object(mod, "run_snapshot")) + for target in DOWNSTREAM_AFTER_SENTINEL["all"]: + stack.enter_context(patch(target)) + mod.handle_command(mod.PRIMARY_COMMAND, [str(tmp_path)]) + + assert touched == [], f"test reached real secret storage: {touched}" + + class TestStubFailsHonestly: """A deferred command must say so, not exit 0 in silence. diff --git a/src/aipass/backup/tests/test_dead_cwd_imports.py b/src/aipass/backup/tests/test_dead_cwd_imports.py new file mode 100644 index 000000000..c054f44a8 --- /dev/null +++ b/src/aipass/backup/tests/test_dead_cwd_imports.py @@ -0,0 +1,767 @@ +# =================== AIPass ==================== +# Name: test_dead_cwd_imports.py +# Description: Dead-cwd import defect — guard shape, safe path helper, both worlds +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""The dead-cwd import defect (Windows round 4). + +``ntpath.realpath`` reads ``os.getcwd()`` UNCONDITIONALLY -- posixpath only does +so for relative paths -- and ``Path.resolve()`` routes through it. So on Windows +every ``resolve()`` REACHED AT IMPORT is an import-time crash for a process whose +cwd was deleted: the module cannot be imported at all. + +Two injections are needed because they convict different code: + +* **World A** wraps ``os.path.realpath`` to read the cwd first, then denies + ``os.getcwd``. This convicts a raw ``resolve()``. It CANNOT convict + ``inspect.stack()`` -- denying getcwd also kills ``abspath``, so ``getmodule`` + dies at ``getabsfile`` inside its own ``except`` and ``stack()`` completes + green for the wrong reason. +* **World B** denies ``os.path.realpath`` directly and leaves ``abspath`` + working. This convicts ``inspect.stack()`` via ``getmodule``'s unguarded + ``realpath`` at inspect.py:1009. + +Every probe rides a **string pseudo-frame** (a ``-c`` child), never stdin: +linecache caches stdin and the probe would report green while lying. + +Measured on this branch 2026-08-31: 57/57 modules red in both worlds before the +cure, 0/57 after. +""" + +import ast +import importlib.util +import json +import os +import subprocess +import sys +import textwrap +from collections.abc import Iterable +from pathlib import Path, PureWindowsPath + +import coverage +import pytest + +from aipass.backup.apps.handlers.path import module_paths + +#: The real guard file. It is loaded from disk rather than imported by name +#: because conftest installs a stub for the handlers package (to keep the +#: cross-branch guard out of the suite's way), and a stub cannot be tested. +TEST_FILE = Path(__file__).resolve() +BRANCH_ROOT = TEST_FILE.parents[1] +GUARD_FILE = BRANCH_ROOT / "apps" / "handlers" / "__init__.py" +SOURCE_TREE = BRANCH_ROOT.parents[2] / "src" / "aipass" + + +def _missing_sources(measured: "Iterable[str]") -> list[str]: + """Measured filenames with no file on disk. + + Exactly the condition ``coverage report`` exits 1 on with "No source for + code". A plain function so the control below can feed it a guilty set. + """ + return sorted(f for f in measured if not os.path.exists(f)) + + +def _compile_sites(source: str) -> list[str]: + """Names of the functions containing a ``compile()`` call. + + A plain function, fed a synthetic file it must convict, rather than a loop + inside the assertion -- a scanner never shown a guilty input is not known to + work (seedgo, round 10). The invariant it serves: exactly ONE mint point, so + the coverage guard in ``_compile_as`` cannot be walked around by a new site. + """ + sites = [] + for parent in ast.walk(ast.parse(source)): + if not isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for node in ast.walk(parent): + if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "compile": + sites.append(parent.name) + break + return sites + + +def _compile_as(caller_file: str, guard) -> None: + """Drive the fence by compiling ``check()`` under a fabricated filename. + + THE ONLY ``compile()`` in this file, so the coverage invariant has exactly + one place to stand. coverage.py records executed code objects by filename, + resolving a RELATIVE one against the cwd at trace time -- so "is this name + inside the source tree" is a question about ``abspath``, not about the + literal. A fabricated name that lands inside the tree and has no file makes + the report step exit 1 with "No source for code" (round 12). + """ + if not caller_file.startswith("<"): + landed = Path(os.path.abspath(caller_file)) + assert SOURCE_TREE not in landed.parents, ( + f"fabricated caller filename lands inside the coverage source tree: {landed}" + ) + exec(compile("check()", caller_file, "exec"), {"check": guard._guard_branch_access}) + + +def _load_guard_module(): + """Execute the real handlers/__init__.py, guard and all. + + The caller frame is this test file, which lives inside the branch, so the + guard's own import-time invocation passes -- that is itself a check that a + kin caller is still allowed. + """ + spec = importlib.util.spec_from_file_location( + "aipass.backup.apps.handlers", + GUARD_FILE, + submodule_search_locations=[str(GUARD_FILE.parent)], + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not build an import spec for {GUARD_FILE}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def guard(): + """The real guard module, loaded once.""" + return _load_guard_module() + + +#: Imported healthy BEFORE any denial. Other branches' cure is their own build; +#: this suite measures backup. Deliberately NOT preloaded: the optional @api +#: import inside drive/client, because a preload is a claim you stop testing. +PRELOAD = ["aipass.prax", "aipass.cli.apps.modules"] + +#: One module per cured site, plus the entry point that pulls the whole tree. +PROBE_MODULES = [ + "aipass.backup.apps", + "aipass.backup.apps.backup", + "aipass.backup.apps.handlers", + "aipass.backup.apps.handlers.state.backup_timestamps", + "aipass.backup.apps.handlers.project.setup", + "aipass.backup.apps.handlers.project.registry", + "aipass.backup.apps.handlers.json.json_handler", + "aipass.backup.apps.handlers.drive.client", +] + +_PROBE = """ +import json, os, os.path, sys +world, targets, preload = sys.argv[1], json.loads(sys.argv[2]), json.loads(sys.argv[3]) +for name in preload: + try: + __import__(name) + except Exception: + pass +_real_realpath = os.path.realpath +def _realpath_reads_cwd(path, *a, **k): + os.getcwd() + return _real_realpath(path, *a, **k) +def _dead_getcwd(*a, **k): + raise FileNotFoundError(2, "probe: cwd denied") +def _dead_realpath(*a, **k): + raise OSError(2, "probe: realpath denied") +if world == "A": + os.path.realpath = _realpath_reads_cwd + os.getcwd = _dead_getcwd +elif world == "B": + os.path.realpath = _dead_realpath +if world == "CONTROL": + os.path.realpath = _realpath_reads_cwd + os.getcwd = _dead_getcwd + from pathlib import Path + try: + Path("relative/thing").resolve() + print(json.dumps({"control": "LIED_GREEN"})) + except Exception as exc: + print(json.dumps({"control": "BITES", "err": type(exc).__name__})) + raise SystemExit(0) +red = {} +for name in targets: + for cached in [m for m in list(sys.modules) if m.startswith("aipass.backup")]: + del sys.modules[cached] + try: + __import__(name) + except Exception as exc: + red[name] = type(exc).__name__ + ": " + str(exc)[:80] +print(json.dumps({"world": world, "red": red})) +""" + + +def _run_probe(world: str, targets: list[str] | None = None) -> dict: + """Run the import probe in a child interpreter across a string frame.""" + proc = subprocess.run( + [ + sys.executable, + "-c", + _PROBE, + world, + json.dumps(targets if targets is not None else PROBE_MODULES), + json.dumps(PRELOAD), + ], + capture_output=True, + text=True, + timeout=180, + ) + assert proc.stdout.strip(), f"probe produced no stdout (stderr: {proc.stderr[-400:]})" + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +class TestTheInjectionCanSayNo: + """A positive control needs its own negative control. + + A CONTROL_LIVE probe that cannot fail turns every pin below vacuously green. + """ + + def test_the_denial_actually_bites(self) -> None: + """Under the world-A injection, a plain resolve() must raise.""" + assert _run_probe("CONTROL")["control"] == "BITES" + + def test_healthy_world_imports_everything(self) -> None: + """With no injection the same probe is green -- so red means the denial.""" + assert _run_probe("HEALTHY")["red"] == {} + + +class TestImportsSurviveADeadCwd: + """No backup module may need a readable cwd in order to be imported.""" + + @pytest.mark.parametrize("world", ["A", "B"]) + def test_no_module_dies_at_import(self, world: str) -> None: + """Every probed module imports with the cwd/realpath denied.""" + assert _run_probe(world)["red"] == {} + + +class TestSafePathHelper: + """module_file degrades to the raw absolute spelling, never to a crash.""" + + def test_returns_resolved_path_normally(self) -> None: + """In a healthy world it behaves like resolve().""" + assert module_paths.module_file(__file__) == Path(__file__).resolve() + + def test_falls_back_when_resolve_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An OSError from resolve() yields an absolute path, not an exception.""" + original = Path.resolve + + def _boom(self, *args, **kwargs): + raise OSError(2, "cwd denied") + + module_paths._REPORTED_DEGRADED.discard(__file__) + monkeypatch.setattr(Path, "resolve", _boom) + result = module_paths.module_file(__file__) + monkeypatch.setattr(Path, "resolve", original) + + assert result.is_absolute() + assert result.name == Path(__file__).name + + def test_branch_root_climbs_without_resolve(self, monkeypatch: pytest.MonkeyPatch) -> None: + """branch_root still answers when resolve() is unavailable.""" + + def _boom(self, *args, **kwargs): + raise OSError(2, "cwd denied") + + monkeypatch.setattr(Path, "resolve", _boom) + assert module_paths.branch_root(__file__, 1).is_absolute() + + +def _inspect_stack_calls(source: str) -> list[int]: + """Line numbers of every literal ``inspect.stack()`` CALL in source.""" + hits = [] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.Call): + continue + func = node.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "stack" + and isinstance(func.value, ast.Name) + and func.value.id == "inspect" + ): + hits.append(node.lineno) + return hits + + +class TestGuardNeverCallsInspectStack: + """An AST ban, because no import-shaped test can reach the deleted walk. + + @trigger measured that the caller-is-None branch is UNREACHABLE from any + import-shaped pin -- apps/__init__ always supplies a real-file frame -- so + restoring the second inspect.stack() walk leaves every behavioural test + green. This pin is the only thing that convicts it. + + It must be an AST ban and never a string ban: the guard's own docstring + names inspect.stack() while explaining the defect, and a spelling ban would + convict the explanation. + """ + + def test_guard_file_has_no_inspect_stack_call(self) -> None: + """The shipped guard contains no inspect.stack() call.""" + assert _inspect_stack_calls(GUARD_FILE.read_text(encoding="utf-8")) == [] + + def test_positive_control_a_real_call_convicts(self) -> None: + """The detector fires on an actual call.""" + source = textwrap.dedent( + """ + import inspect + def f(): + return inspect.stack() + """ + ) + assert _inspect_stack_calls(source) == [4] + + def test_negative_control_a_docstring_mention_is_not_a_call(self) -> None: + """Prose naming inspect.stack() must not convict.""" + source = '"""Uses sys._getframe rather than inspect.stack() -- see defect."""\n' + assert _inspect_stack_calls(source) == [] + + def test_negative_control_numpy_stack_is_not_inspect_stack(self) -> None: + """A different module's stack() must not convict.""" + source = "import numpy\nnumpy.stack([1, 2])\n" + assert _inspect_stack_calls(source) == [] + + def test_the_guard_docstring_really_does_name_it(self) -> None: + """Guards the negative control above: if the prose ever stops mentioning + inspect.stack(), the docstring control is no longer testing anything.""" + assert "inspect.stack()" in GUARD_FILE.read_text(encoding="utf-8") + + +def _fake_tree(tmp_path: Path) -> Path: + """A tree-SHAPED root that is not the tree. + + The fence is driven by compiling ``check()`` under a fabricated filename, + and coverage.py records every executed code object BY FILENAME whether or + not the file exists. Fabricating under the REAL tree therefore writes + measurement for a path inside coverage's source filter: if the file does + not exist the report step dies with "No source for code" (round 12, the + coverage leg on 5bfd5b63), and if it DOES exist the run forges a covered + line in a file that never ran. Both are the same mistake. + + Everything fabricated here lives under ``tmp_path`` -- outside the source + filter, never recorded -- while keeping the ``aipass/`` segments the + guard's branch-name extraction reads. + """ + return tmp_path / "src" / "aipass" + + +class TestFenceStillRefusesForeignCallers: + """Curing the crash must not quietly open the fence. + + The guard reads ``f_code.co_filename`` off the calling frame, so a foreign + caller is simulated by compiling the call under a foreign filename. That is + the same mechanism a real cross-branch import presents. + + Every fabricated filename here is under ``tmp_path``; the REAL-tree + adjacency properties are pinned on ``_is_kin`` instead, which is pure and + compiles nothing. + """ + + @staticmethod + def _call_guard_as(guard, caller_file: str) -> None: + _compile_as(caller_file, guard) + + def test_foreign_caller_is_refused(self, guard, tmp_path: Path) -> None: + """A caller outside the branch root raises ImportError.""" + with pytest.raises(ImportError, match="ACCESS DENIED"): + self._call_guard_as(guard, str(tmp_path / "outsider.py")) + + def test_sibling_branch_is_refused(self, guard, tmp_path, monkeypatch) -> None: + """Another citizen's file is refused, and named in the message.""" + fake = _fake_tree(tmp_path) + monkeypatch.setattr(guard, "_BRANCH_ROOT", str(fake / "backup")) + sibling = str(fake / "memory" / "apps" / "x.py") + with pytest.raises(ImportError, match="memory"): + self._call_guard_as(guard, sibling) + + def test_the_real_sibling_path_is_foreign_too(self, guard) -> None: + """The near-miss, against the REAL tree, without compiling anything. + + ``src/aipass/memory`` shares the longest possible prefix with + ``src/aipass/backup`` short of being it. That is the property the old + compiled version carried, kept here where it mints no code object. + """ + real_sibling = str(Path(guard._BRANCH_ROOT).parent / "memory" / "apps" / "x.py") + assert not guard._is_kin(real_sibling, guard._BRANCH_ROOT) + + def test_own_branch_file_is_allowed(self, guard, tmp_path, monkeypatch) -> None: + """A file under the branch root passes -- the fence is not always-refuse.""" + fake = _fake_tree(tmp_path) + monkeypatch.setattr(guard, "_BRANCH_ROOT", str(fake / "backup")) + self._call_guard_as(guard, str(fake / "backup" / "apps" / "modules" / "snapshot.py")) + + def test_a_real_backup_file_is_kin(self, guard) -> None: + """The allow side against the REAL root, again without compiling.""" + real_kin = str(Path(guard._BRANCH_ROOT) / "apps" / "modules" / "snapshot.py") + assert guard._is_kin(real_kin, guard._BRANCH_ROOT) + + def test_pseudo_frame_caller_is_allowed(self, guard) -> None: + """A frame is skipped, not resolved -- that skip is the cure.""" + self._call_guard_as(guard, "") + + +class TestKinshipSurvivesTheWindowsSpelling: + """The fence must recognise its OWN files when paths are spelled Windows. + + Round 5, from the windows-setup runner on 28ee90d5: every test in this file + errored at import with backup's own ACCESS DENIED message -- the fence was + refusing backup's own ``apps/__init__.py``. + + The mechanism is one-sided normalisation. The guard normalised the CALLER + (``caller_file.replace("\\\\", "/")``) and compared it against a + ``_BRANCH_ROOT`` that came straight from ``Path``, i.e. spelled with + BACKSLASHES on Windows. A forward-slashed caller can never contain a + backslashed root, so kinship failed for every file in the branch and the + door import that arms the fence raised instead. + + Reproduced on Linux by fabricating the Windows spelling with + ``PureWindowsPath`` -- the bug needs a backslash, not a Windows box. + """ + + WIN_ROOT = PureWindowsPath(r"C:\Actions\AIPass\src\aipass\backup") + WIN_KIN = WIN_ROOT / "apps" / "__init__.py" + + def test_windows_spelled_kin_is_recognised(self, guard) -> None: + """The exact CI shape: backslashed root, backslashed own file.""" + assert guard._is_kin(str(self.WIN_KIN), str(self.WIN_ROOT)) + + def test_the_fabrication_really_carries_backslashes(self) -> None: + """Control: if PureWindowsPath rendered POSIX here, the pin proves nothing.""" + assert "\\" in str(self.WIN_KIN) + assert "/" not in str(self.WIN_KIN) + + def test_mixed_spellings_agree(self, guard) -> None: + """Either side may arrive in either dialect; both readings are kin.""" + assert guard._is_kin(str(self.WIN_KIN), self.WIN_ROOT.as_posix()) + assert guard._is_kin(self.WIN_KIN.as_posix(), str(self.WIN_ROOT)) + + def test_drive_letter_case_folds_on_windows(self, guard) -> None: + """C: vs c: is the same drive. Windows folds case; the comparison must.""" + lowered = str(self.WIN_KIN).replace("C:", "c:", 1) + assert guard._is_kin(lowered, str(self.WIN_ROOT), windows=True) + + def test_case_does_not_fold_on_posix(self, guard) -> None: + """The negative control for the fold: POSIX case-sensitivity is not weakened. + + Folding unconditionally would ADMIT a foreign BACKUP dir under a temp + root on Linux, so + the fold is gated on the platform rather than applied to be safe. + """ + root = "/home/x/src/aipass/backup" + assert not guard._is_kin(f"{root.upper()}/apps/evil.py", root, windows=False) + assert guard._is_kin(f"{root}/apps/ok.py", root, windows=False) + + def test_foreign_windows_caller_is_still_refused(self, guard) -> None: + """Separator-safety must not turn into admit-everything.""" + foreign = PureWindowsPath(r"C:\Actions\AIPass\src\aipass\memory\apps\x.py") + assert not guard._is_kin(str(foreign), str(self.WIN_ROOT)) + + def test_guard_allows_windows_spelled_own_file_end_to_end(self, guard, monkeypatch, tmp_path: Path) -> None: + """The whole decision, not just the helper -- this is what CI ran. + + Red before the cure: ImportError, ACCESS DENIED, on backup's own file, + with "Caller branch: backup" in the message -- the fence naming ITSELF + as the foreigner, which is exactly what the runner printed. + + Noted for the next reader: on Linux a drive-lettered path is RELATIVE, + so ``_find_real_caller`` resolves it under cwd and the branch root is + prefixed onto it. The kinship substring is still the thing under test + (this pin was red before the cure), but the pure-function pins above + are the ones that carry the argument without that artifact. + """ + monkeypatch.setattr(guard, "_BRANCH_ROOT", str(self.WIN_ROOT)) + monkeypatch.chdir(tmp_path) + _compile_as(str(self.WIN_KIN), guard) + + def test_guard_still_refuses_windows_spelled_foreigner(self, guard, monkeypatch, tmp_path: Path) -> None: + """Same end-to-end path, refuse side -- the fence still closes.""" + monkeypatch.setattr(guard, "_BRANCH_ROOT", str(self.WIN_ROOT)) + monkeypatch.chdir(tmp_path) + foreign = PureWindowsPath(r"C:\Actions\AIPass\src\aipass\memory\apps\x.py") + with pytest.raises(ImportError, match="ACCESS DENIED"): + _compile_as(str(foreign), guard) + + def test_self_skip_uses_the_same_spelling_rule(self, guard) -> None: + """The guard's own-frame skip is the same comparison, and it matters more. + + If the self-skip misses, ``__init__.py`` itself is returned as the + caller -- trivially kin -- and the foreign frame beneath it is never + looked at. A case-fragile self-skip OPENS the fence. + """ + source = GUARD_FILE.read_text(encoding="utf-8") + assert "if this_file in resolved:" not in source + assert source.count("_spell_for_kinship(") >= 4 + + +#: Behavioural sibling to the AST ban, spawn's shape (relayed by devpulse). +#: The round-4 guidance said the caller-is-None branch is unreachable and only a +#: parse-tree pin can watch it. The true sentence is narrower: it is unreachable +#: from IMPORT-shaped pins. Called DIRECTLY from a ``-c`` child every frame is +#: string-pseudo or importlib, both skipped, ``_find_real_caller`` returns None, +#: and the branch RUNS -- so a regrown ``inspect.stack()`` walk dies there under +#: a realpath denial while the cured plain return survives. +_NONE_BRANCH_PROBE = textwrap.dedent( + """ + import json, os, os.path, sys + # Imported BEFORE the denial: the guard's own import needs realpath. + import aipass.backup.apps.handlers as g + + result = {} + def _denied(*a, **k): + raise OSError(9999, "realpath denied") + os.path.realpath = _denied + + # ARMING PROBE 1 -- MEASURE, do not assert, whether the denial reaches the + # construct under test. inspect.stack() routes to os.path.realpath through + # getmodule (3.12: inspect.py:1009), and an interpreter that spells that + # route differently makes this world inert rather than wrong. The child + # reports; the parent decides (seedgo round 6: an arming probe must measure + # the call ITS OWN WORLD denies, and the ordering travels, the line does not). + import inspect + try: + inspect.stack() + result["denial_bites"] = False + result["stack_route"] = "inspect.stack() returned; no realpath on the route" + except OSError as exc: + result["denial_bites"] = exc.errno == 9999 + result["stack_route"] = "inspect.stack() raised OSError " + str(exc.errno) + + # ARMING PROBE 2 -- the branch under test must be the one entered. Without + # this the world could silently exercise the ordinary kin path instead. + try: + result["caller_is_none"] = g._find_real_caller() == (None, None) + except BaseException as exc: + result["caller_is_none"] = "raised " + type(exc).__name__ + + # THE PIN. + try: + g._guard_branch_access() + result["guard_returns"] = True + except BaseException as exc: + result["guard_returns"] = "raised " + type(exc).__name__ + ": " + str(exc) + + print(json.dumps(result)) + """ +) + + +def _behavioural_verdict(report: dict) -> str: + """ASSERT where the denial reaches inspect.stack(), STAND_DOWN where it does not. + + A literal two-row table rather than an arm that only fires on a host I do + not have (spawn/devpulse round 11, fleet line A4): both rows are reachable + from any interpreter because the input is a measured value, not the host. + """ + return "ASSERT" if report.get("denial_bites") is True else "STAND_DOWN" + + +class TestTheCallerIsNoneBranchIsWatchedBehaviourally: + """The deleted second stack walk, pinned by RUNNING it -- not only by shape. + + ``-c`` and never a script: run this as a file and every frame is a real + on-disk path, ``getsourcefile`` early-returns, and the denial is silently + inert (commons, round 4). + """ + + @staticmethod + def _run() -> dict: + proc = subprocess.run( + [sys.executable, "-c", _NONE_BRANCH_PROBE], + capture_output=True, + text=True, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + def test_the_verdict_table_has_both_rows(self) -> None: + """Host-already-has-one control: the stand-down arm is reachable here. + + Round 10's M6 -- a new world needs its stand-down pinned on day one, or + the mutant that deletes it is invisible on the only machine I can run. + """ + armed = {"denial_bites": True, "stack_route": "raised"} + inert = {"denial_bites": False, "stack_route": "returned"} + assert _behavioural_verdict(armed) == "ASSERT" + assert _behavioural_verdict(inert) == "STAND_DOWN" + assert _behavioural_verdict({}) == "STAND_DOWN" + + def test_this_host_is_armed(self) -> None: + """And on THIS interpreter the world is armed -- reported, not assumed.""" + report = self._run() + assert _behavioural_verdict(report) == "ASSERT", report["stack_route"] + + def test_the_none_branch_is_actually_entered(self) -> None: + """Arming probe: _find_real_caller returned None, so the branch ran. + + Version-independent -- it rests on frame filenames, not on any route. + """ + assert self._run()["caller_is_none"] is True + + def test_the_guard_returns_instead_of_walking(self) -> None: + """The pin. A regrown inspect.stack() walk raises OSError here. + + Stands down with the child's own reason on an interpreter where + inspect.stack() never reaches os.path.realpath: there the world is + inert, so asserting would be claiming a property this host cannot + contradict. The AST ban still watches the branch everywhere. + """ + report = self._run() + if _behavioural_verdict(report) == "STAND_DOWN": + pytest.skip(f"world inert here: {report['stack_route']}") + assert report["guard_returns"] is True + + +#: Classes whose tests compile ``check()`` under a fabricated filename. Run +#: under coverage below; anything added here is covered by the same pin. +_COMPILING_CLASSES = ( + "TestFenceStillRefusesForeignCallers", + "TestKinshipSurvivesTheWindowsSpelling", +) + + +class TestFabricatedFilenamesNeverReachCoverage: + """Round 12: the fence pins made the COVERAGE leg red with zero test failures. + + coverage.py records every executed code object by filename, existing file or + not. ``test_sibling_branch_is_refused`` compiled ``check()`` under + ``src/aipass/memory/apps/x.py`` -- inside coverage's ``source`` filter and + absent from disk -- so the suite passed and the REPORT step exited 1 with + "No source for code". It never bit before because every earlier coverage leg + died at a test failure first. + + Two instruments, because they fail for different reasons: this one runs the + real report step, the structural one below refuses the shape without a + subprocess. + """ + + @staticmethod + def _coverage_run(tmp_path: Path, cwd: Path) -> Path: + """Run the compiling tests under coverage from ``cwd``; return the data file.""" + data_file = tmp_path / f"cov-{cwd.name}.dat" + env = {**os.environ, "COVERAGE_FILE": str(data_file)} + selected = [f"{TEST_FILE}::{name}" for name in _COMPILING_CLASSES] + run = subprocess.run( + [ + sys.executable, + "-m", + "coverage", + "run", + "--rcfile=/dev/null", + f"--source={SOURCE_TREE}", + "-m", + "pytest", + *selected, + "-q", + "-p", + "no:cacheprovider", + ], + cwd=cwd, + env=env, + capture_output=True, + text=True, + timeout=600, + ) + assert run.returncode == 0, f"the tests themselves failed:\n{run.stdout}" + return data_file + + @pytest.mark.skipif( + sys.platform == "win32", + reason="skipped on Windows by the 2026-09-01 one-fix ruling: the inner " + "coverage run failed on the real Windows host (5dee751a, windows-setup) " + "- unverifiable from this box; the pin stays live on POSIX where the " + "report-step landmine was caught; owner to diagnose after PR 750", + ) + @pytest.mark.parametrize("from_repo_root", [True, False], ids=["repo_cwd", "branch_cwd"]) + def test_no_measured_file_is_missing_from_disk(self, tmp_path: Path, from_repo_root: bool) -> None: + """Both cwds, because cwd decides -- and this is the cheap half. + + coverage resolves a relative fabricated filename against the cwd at + trace time, so the same pin is inert from one directory and a minter + from another. The repo-root run is what CI does. The branch run is what + the two-rootdir rule does, and it is the one that caught the + Windows-spelled literals: relative on POSIX, so they land inside the + source tree whenever the cwd does. + + A measured file with no source on disk is exactly the condition the + report step exits 1 on. Asserted on the data directly here because + ``coverage report`` parses every file in the tree and costs ~30s; the + run below pays that once, from the stronger cwd. + """ + cwd = SOURCE_TREE.parents[1] if from_repo_root else BRANCH_ROOT + data = coverage.CoverageData(basename=str(self._coverage_run(tmp_path, cwd))) + data.read() + assert _missing_sources(data.measured_files()) == [] + + @pytest.mark.skipif( + sys.platform == "win32", + reason="skipped on Windows by the 2026-09-01 one-fix ruling: the inner " + "coverage run failed on the real Windows host (5dee751a, windows-setup) " + "- unverifiable from this box; the pin stays live on POSIX where the " + "report-step landmine was caught; owner to diagnose after PR 750", + ) + def test_the_real_report_step_survives(self, tmp_path: Path) -> None: + """The actual CI command, run once, from the cwd that catches the most. + + The branch cwd is a superset: it sees the absolute ``_BRANCH_ROOT`` + minter (cwd-independent) AND the relative Windows-spelled ones. Paying + the ~30s once here keeps the pin standing on the real step rather than + on my model of what that step checks. + """ + env = {**os.environ, "COVERAGE_FILE": str(self._coverage_run(tmp_path, BRANCH_ROOT))} + report = subprocess.run( + [sys.executable, "-m", "coverage", "report", "--rcfile=/dev/null"], + cwd=BRANCH_ROOT, + env=env, + capture_output=True, + text=True, + timeout=600, + ) + assert "No source for code" not in (report.stdout + report.stderr) + assert report.returncode == 0, report.stderr + + def test_no_compiled_filename_is_under_the_source_tree(self) -> None: + """Structural sibling: refuse the shape, no subprocess needed. + + A future pin that fabricates under the real ``_BRANCH_ROOT`` again fails + here before it ever reaches a coverage leg. + """ + assert _compile_sites(TEST_FILE.read_text(encoding="utf-8")) == ["_compile_as"] + + def test_the_missing_source_predicate_can_say_no(self, tmp_path: Path) -> None: + """Control for the cheap half: the predicate must convict a guilty set. + + Without it, blanking the predicate leaves every pin green -- a clean + tree cannot tell a working check from a deleted one. + """ + present = tmp_path / "real.py" + present.write_text("x = 1\n", encoding="utf-8") + absent = str(tmp_path / "src" / "aipass" / "memory" / "apps" / "x.py") + assert _missing_sources([str(present), absent]) == [absent] + + def test_the_mint_guard_refuses_a_tree_shaped_name(self, guard) -> None: + """The runtime guard's own control -- it must be able to REFUSE. + + Without this, deleting the assert inside ``_compile_as`` is invisible: + every existing site is already safe, so nothing else would go red. + """ + with pytest.raises(AssertionError, match="coverage source tree"): + _compile_as(str(BRANCH_ROOT / "apps" / "never_written.py"), guard) + + def test_the_mint_guard_permits_a_pseudo_frame(self, guard) -> None: + """Narrowness control: has no source and must stay permitted.""" + _compile_as("", guard) + + def test_the_structural_check_can_say_no(self) -> None: + """Negative control: a checker that cannot convict is not a checker. + + Feeds the SAME function a synthetic file carrying one offending site and + two innocent ones, so a mutant that blinds the scanner kills this pin + too. A control that re-implements the check is a second copy of it + wearing a control's name (spawn, round 4). + """ + synthetic = textwrap.dedent( + """ + def _compile_as(name, guard): + exec(compile("check()", name, "exec"), {}) + + def a_second_mint_point(name): + return compile("check()", name, "exec") + """ + ) + assert _compile_sites(synthetic) == ["_compile_as", "a_second_mint_point"] diff --git a/src/aipass/canary/README.md b/src/aipass/canary/README.md index 1a8c3476d..84fff0265 100644 --- a/src/aipass/canary/README.md +++ b/src/aipass/canary/README.md @@ -3,7 +3,7 @@ **Purpose:** Permanent test citizen. Exists to be spawned, dispatched, resumed, broken and re-scaffolded so the working fleet never is. All mail, logs and memories here are TEST DATA by definition — never production work. Sibling of @finch (projects tier) and @wren (external tier): three homes covering three different fence contexts. **Module:** `aipass.canary` **Created:** 2026-08-20 -**Last Updated:** 2026-08-25 +**Last Updated:** 2026-08-31 --- @@ -24,10 +24,17 @@ Run the branch's own suite from the repo root, which is how CI runs it: pytest src/aipass/canary/tests -v ``` -Three of those functions are parametrized, so pytest collects and passes more -cases than there are `def test_` lines. Both counts are true of different -things; the tree below states the function count, which is what the standards -audit measures. +Several of those functions are parametrized, so pytest collects and passes +more cases than there are `def test_` lines — 52 functions collect as 82 cases +today. Both counts are true of different things; the tree below states the +function count, which is what the standards audit measures. + +The suite must also pass from the repo root, which is the shape CI actually +runs and a different universe from the branch rootdir: + +```bash +pytest src/aipass/canary/tests -c pyproject.toml --rootdir=. +``` --- @@ -57,12 +64,13 @@ CANARY/ │ ├── canary.py # Entry point │ ├── modules/ # Business logic — no .py here by design, added per test │ ├── handlers/ +│ │ ├── paths.py # Dead-cwd-safe resolve for module-level constants │ │ └── json/ # JSON handler shim over aipass.aipass.shared │ ├── integrations/ # Scaffold, empty │ └── plugins/ # Scaffold, empty ├── artifacts/ # Test artifacts written during dispatches ├── canary_json/ # Where the json shim writes — test data, nothing depends on it -├── tests/ # 38 test functions, all passing as of 2026-08-25 +├── tests/ # 52 test functions, all passing as of 2026-08-31 ├── docs/ └── README.md ``` diff --git a/src/aipass/canary/apps/handlers/__init__.py b/src/aipass/canary/apps/handlers/__init__.py index d31b95eca..d34583c9d 100644 --- a/src/aipass/canary/apps/handlers/__init__.py +++ b/src/aipass/canary/apps/handlers/__init__.py @@ -1,6 +1,7 @@ """CANARY handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "aipass.canary" @@ -11,24 +12,54 @@ def _find_real_caller(): Skips this file, importlib internals, and frozen modules. Returns tuple: (file_path, import_line) or (None, None). - """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) - - for frame_info in stack: - filename = frame_info.filename - if this_file in str(Path(filename).resolve()): + Walks frames with sys._getframe rather than inspect.stack(): inspect + builds a FrameInfo per frame, and getmodule() calls os.path.realpath() + outside any try (inspect.py:1009) — while ntpath.realpath reads + os.getcwd() UNCONDITIONALLY, unlike posixpath which reads it only for + relative paths. So on Windows a process whose cwd is unreadable cannot + import this package at all. Measured here 2026-08-31: denying realpath + convicted line 15 (inspect.stack) and denying getcwd convicted line 16 + (the raw resolve) — two separate species in one function. A frame's + co_filename is already a string in memory; reading it touches nothing. + """ + # Path.resolve() reaches the same ntpath.realpath. __file__ is already + # absolute, so the resolve only normalises it — the raw spelling is a + # correct answer when the cwd is gone. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back continue - if filename.startswith("<") or "importlib" in filename: + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename + + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + # linecache reads one named file and returns "" rather than raising. + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -52,10 +83,10 @@ def _guard_branch_access(): caller_file, import_line = _find_real_caller() if caller_file is None: - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return + # No caller outside this file: an interactive session, a -c script, or + # an importlib-only stack — all allowed. The old second inspect.stack() + # walk here returned on every path, so it could not change this answer; + # it was a second copy of the cwd dependency in service of nothing. return branch_path = "/" + MY_BRANCH.replace(".", "/") + "/" diff --git a/src/aipass/canary/apps/handlers/json/json_handler.py b/src/aipass/canary/apps/handlers/json/json_handler.py index d765f35e1..f888b33ca 100644 --- a/src/aipass/canary/apps/handlers/json/json_handler.py +++ b/src/aipass/canary/apps/handlers/json/json_handler.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: json_handler.py # Description: Canary JSON handler — configured instance of aipass.aipass.shared -# Version: 1.0.0 +# Version: 1.1.0 # Created: 2026-08-22 -# Modified: 2026-08-22 +# Modified: 2026-08-31 # ============================================= """Canary JSON handler — thin shim over aipass.aipass.shared.json_handler. @@ -16,11 +16,14 @@ the rest of the fleet does, not because canary has state worth keeping. """ -from pathlib import Path - from aipass.aipass.shared.json_handler import JsonHandler -_CANARY_ROOT = Path(__file__).resolve().parents[3] +from ..paths import module_file + +# module_file, not Path(__file__).resolve(): this constant is built AT IMPORT, +# and ntpath.realpath reads the cwd unconditionally, so the bare resolve makes +# this module unimportable on Windows in a process whose cwd is gone. +_CANARY_ROOT = module_file(__file__).parents[3] _JSON_DIR = _CANARY_ROOT / "canary_json" _handler = JsonHandler(json_dir=_JSON_DIR) diff --git a/src/aipass/canary/apps/handlers/paths.py b/src/aipass/canary/apps/handlers/paths.py new file mode 100644 index 000000000..bf11de373 --- /dev/null +++ b/src/aipass/canary/apps/handlers/paths.py @@ -0,0 +1,60 @@ +# =================== AIPass ==================== +# Name: paths.py +# Description: Dead-cwd-safe path resolution for canary module-level constants +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""One guarded resolve for every module-level path constant in canary. + +THE DEFECT THIS EXISTS FOR. ntpath.realpath reads os.getcwd() +UNCONDITIONALLY — posixpath reads it only for relative paths — and +Path.resolve() routes through it. So on Windows every Path(__file__).resolve() +REACHED AT IMPORT is an import-time crash in a process whose cwd is +unreadable: the module cannot be imported at all. The discriminator is +reached-at-import, not written-at-module-scope. + +STDLIB ONLY, DELIBERATELY. This module must not import prax: the logger's +own construction reads the cwd, which would put the disease onto the path +the cure is protecting (backup's ruling, adopted here). Diagnostics go to +sys.stderr and nowhere else. +""" + +import sys +from pathlib import Path + +# Paths already reported on. A dead cwd makes EVERY resolve in the process +# fail, so an undeduped report buries the real traceback under its own noise +# (backup's addition to daemon's rule). +_REPORTED: set = set() + + +def module_file(dunder_file: str) -> Path: + """Return an absolute Path for a module's __file__, cwd or no cwd. + + Args: + dunder_file: The calling module's __file__. + + Returns: + The resolved path, or the raw absolute spelling when the cwd is + unreadable. Since Python 3.9 __file__ is already absolute, so the + fallback is a correct answer and not a degraded one — resolve() + only normalises symlinks and '..' segments on top of it. + """ + try: + return Path(dunder_file).resolve() + except OSError as exc: + # The diagnostic lives INSIDE its own protection (daemon's rule): a + # report that raises while reporting replaces one failure with two. + try: + if dunder_file not in _REPORTED: + _REPORTED.add(dunder_file) + sys.stderr.write( + f"[canary.paths] resolve() failed for {dunder_file} " + f"({type(exc).__name__}: {exc}); using the raw absolute " + f"spelling. This process has no readable cwd.\n" + ) + except OSError: + pass + return Path(dunder_file) diff --git a/src/aipass/canary/tests/test_dead_cwd_imports.py b/src/aipass/canary/tests/test_dead_cwd_imports.py new file mode 100644 index 000000000..2338187b1 --- /dev/null +++ b/src/aipass/canary/tests/test_dead_cwd_imports.py @@ -0,0 +1,495 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: tests/test_dead_cwd_imports.py +# Date: 2026-08-31 +# Version: 1.0.0 +# Category: canary/tests +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-08-31): Windows dead-cwd import defect — two-world subprocess +# pins, live-injection controls, and the AST ban on inspect.stack +# +# CODE STANDARDS: +# - Error handling: Use error handler system (apps/handlers/error/) +# ============================================= + +"""Pins for the Windows dead-cwd import defect (FPLAN round 4). + +THE DEFECT. ntpath.realpath reads os.getcwd() UNCONDITIONALLY, while +posixpath reads it only for relative paths. Path.resolve() routes through +realpath. So on Windows any resolve REACHED AT IMPORT makes the module +unimportable in a process whose cwd is unreadable. The discriminator is +reached-at-import, not written-at-module-scope — which is why these pins +RUN the import in a child rather than grepping for the spelling. + +TWO WORLDS, because one cannot convict both species: + + World A wraps realpath to read the cwd first (emulating ntpath on this + POSIX box), then denies os.getcwd. Convicts a raw Path.resolve(). + It CANNOT convict inspect.stack: denying getcwd also kills + os.path.abspath, so inspect.getmodule dies at getabsfile INSIDE + its own except clause and stack() completes green for the wrong + reason (measured by @daemon, reproduced here). + + World B denies os.path.realpath directly and leaves abspath working. + Convicts inspect.stack() via getmodule's unguarded realpath at + inspect.py:1009. + +Measured against canary's own tree on 2026-08-31, BEFORE the cure: +World A convicted handlers/__init__.py:16 (the raw resolve) and World B +convicted handlers/__init__.py:15 (inspect.stack) — two species in one +function — with json_handler.py:23 masked underneath both. + +Every child probe rides a '' pseudo-frame via python -c. NEVER +stdin: linecache caches stdin, and the probe would lie green (@hooks). +""" + +import ast +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +BRANCH_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = BRANCH_ROOT.parents[2] +GUARD_FILE = BRANCH_ROOT / "apps" / "handlers" / "__init__.py" + +# Every importable module in canary's tree. +CANARY_MODULES = [ + "aipass.canary", + "aipass.canary.apps", + "aipass.canary.apps.canary", + "aipass.canary.apps.handlers", + "aipass.canary.apps.handlers.paths", + "aipass.canary.apps.handlers.json", + "aipass.canary.apps.handlers.json.json_handler", + "aipass.canary.apps.modules", + "aipass.canary.apps.plugins", +] + +# Non-optional cross-branch imports, preloaded in the healthy world so the +# denial convicts canary's own code rather than a sibling's. None of these +# sits behind try/except ImportError, so preloading them stops testing +# nothing that was under test (@flow's rule: a preload is a claim you stop +# testing). +PRELOAD = [ + "aipass.cli.apps.modules", + "aipass.prax", + "aipass.aipass.shared.json_handler", +] + +_WORLD_A = """ +import os, os.path +_real = os.path.realpath +def realpath(p, *a, **k): + os.getcwd() + return _real(p, *a, **k) +os.path.realpath = realpath +def _dead_cwd(*a, **k): + raise FileNotFoundError(2, "No such file or directory") +os.getcwd = _dead_cwd +""" + +_WORLD_B = """ +import os, os.path +def _dead_realpath(*a, **k): + raise FileNotFoundError(2, "No such file or directory") +os.path.realpath = _dead_realpath +""" + +WORLDS = {"A": _WORLD_A, "B": _WORLD_B} + + +def _run_child(body: str) -> subprocess.CompletedProcess: + """Run body in a child interpreter on a '' pseudo-frame.""" + return subprocess.run( + [sys.executable, "-c", body], + capture_output=True, + text=True, + timeout=120, + cwd=str(REPO_ROOT), + ) + + +def _import_probe(module: str, world: str, inject: bool = True) -> subprocess.CompletedProcess: + """Import `module` under `world`. inject=False kills the injection.""" + body = ( + "".join(f"import {m}\n" for m in PRELOAD) + (WORLDS[world] if inject else "") + f"import {module}\n" + "print('PROBE_OK')\n" + ) + return _run_child(body) + + +@pytest.mark.parametrize("module", CANARY_MODULES) +@pytest.mark.parametrize("world", sorted(WORLDS)) +def test_module_imports_with_no_readable_cwd(module, world): + """Every canary module imports in a process whose cwd is unreadable.""" + result = _import_probe(module, world) + assert "PROBE_OK" in result.stdout, f"{module} failed to import in world {world}:\n{result.stderr[-2000:]}" + + +@pytest.mark.parametrize("world", sorted(WORLDS)) +def test_the_injection_is_live(world): + """POSITIVE CONTROL: the denial actually denies. + + Without this, a typo in the injection turns every pin above into a + vacuous green — the modules would 'survive' a world that was never + hostile. Runs the exact construct the defect used. + """ + body = WORLDS[world] + ( + "from pathlib import Path\n" + "try:\n" + " Path(__file__ if '__file__' in dir() else '/etc/hostname').resolve()\n" + " print('CONTROL_DEAD')\n" + "except OSError:\n" + " print('CONTROL_LIVE')\n" + ) + result = _run_child(body) + assert "CONTROL_LIVE" in result.stdout, ( + f"world {world} injection did not deny resolve — every pin in this " + f"file is vacuous until it does:\n{result.stdout}\n{result.stderr[-1000:]}" + ) + + +@pytest.mark.parametrize("world", sorted(WORLDS)) +def test_control_catches_a_dead_injection(world): + """NEGATIVE CONTROL FOR THE POSITIVE CONTROL (@spawn's instrument). + + Kill the injection and the liveness probe must report CONTROL_DEAD. A + control that cannot say NO proves nothing when it says YES. + """ + body = ( + "from pathlib import Path\n" + "try:\n" + " Path('/etc/hostname').resolve()\n" + " print('CONTROL_DEAD')\n" + "except OSError:\n" + " print('CONTROL_LIVE')\n" + ) + result = _run_child(body) + assert "CONTROL_DEAD" in result.stdout, ( + "with no injection the probe still reported a denial — the probe is " + f"not measuring the injection:\n{result.stdout}\n{result.stderr[-1000:]}" + ) + + +# The world-A/inspect.stack outcome is PLATFORM-DEPENDENT, and the deciding +# fact is whether os.path.abspath survives a getcwd denial: +# +# POSIX posixpath.abspath calls os.getcwd() for a relative path, so the +# denial kills it. getabsfile raises inside getmodule's OWN except, +# getmodule returns None, and inspect.stack() never reaches the +# unguarded realpath at inspect.py:1009 -> STACK SURVIVES. +# MEASURED HERE, live, by test_world_a_abspath_outcome below. +# +# WINDOWS ntpath.abspath rides the Win32 _getfullpathname and never touches +# getcwd, so it SURVIVES the denial. getmodule proceeds to realpath +# - which world A wraps to read getcwd first - and it dies there +# -> STACK DIES. Windows convicts inspect.stack naturally, which is +# why the platform found this defect and POSIX did not. +# MEASURED ON CI, not here: this pin's own round-4 assertion text +# came back red from windows-setup on 28ee90d5 (run 33431848734). +# That failure IS the measurement; canary has no Windows box. +# +# Neither outcome is a bug. World B exists because the POSIX half cannot +# convict inspect.stack; on Windows world A already does. Deleting either +# branch of this table retires a live measurement on one platform. + +STACK_SURVIVES_WORLD_A = os.name != "nt" + + +def test_world_a_abspath_outcome_is_the_mechanism(): + """The fact the table above turns on, measured on whatever platform runs. + + Asserting the mechanism rather than only the outcome means a future + interpreter that changes the abspath/getcwd coupling reds HERE, naming + the cause, instead of reding the stack pin with no explanation. + """ + body = _WORLD_A + ( + "import os, os.path\n" + "try:\n" + " os.path.abspath('rel.py')\n" + " print('ABSPATH_SURVIVED')\n" + "except OSError:\n" + " print('ABSPATH_DIED')\n" + ) + result = _run_child(body) + expected = "ABSPATH_DIED" if os.name != "nt" else "ABSPATH_SURVIVED" + assert expected in result.stdout, ( + f"on {os.name}, world A's getcwd denial was expected to produce " + f"{expected} from os.path.abspath. If this changed, the two-world " + f"split and the stack table below both need re-deriving, not " + f"deleting:\n{result.stdout}\n{result.stderr[-800:]}" + ) + + +def test_world_a_stack_outcome_matches_the_platform_table(): + """world A convicts inspect.stack on Windows and not on POSIX. + + Round 4 pinned the POSIX half as universal. It is not: windows-setup + turned this assertion red on the real platform, which is the pin doing + its job on a world it had never run in. The table is per-platform now. + """ + body = _WORLD_A + ( + "import inspect\n" + "try:\n" + " inspect.stack()\n" + " print('STACK_SURVIVED')\n" + "except OSError:\n" + " print('STACK_DIED')\n" + ) + result = _run_child(body) + expected = "STACK_SURVIVED" if STACK_SURVIVES_WORLD_A else "STACK_DIED" + assert expected in result.stdout, ( + f"world A on {os.name} was expected to produce {expected} from " + f"inspect.stack(). See the platform table above: re-derive the table " + f"rather than deleting the pin, and check " + f"test_world_a_abspath_outcome_is_the_mechanism first — it names the " + f"cause:\n{result.stdout}\n{result.stderr[-800:]}" + ) + + +def test_the_windows_half_of_the_table_reproduces_here(): + """Run the WINDOWS branch's mechanism on this platform, whatever it is. + + The table's Windows row would otherwise be an untested claim on every + machine canary can actually run on — true today because CI said so, and + unfalsifiable here forever after. The deciding fact is abspath surviving + the getcwd denial, and that is emulable: patch abspath to ignore the cwd + exactly as ntpath's _getfullpathname does, keep world A otherwise + intact, and inspect.stack() must then die at the unguarded realpath. + + This does NOT make canary a Windows box and is not evidence about the + real platform — @daemon and @cli measured the same reconciliation + directly. It pins the CAUSAL LINK the table asserts, so the Windows row + cannot quietly stop being true between CI runs. + """ + body = _WORLD_A + ( + "import os, os.path, inspect\n" + "os.path.abspath = lambda p, *a, **k: p if p.startswith('/') else '/fake/' + p\n" + "try:\n" + " os.path.abspath('rel.py')\n" + " print('ABSPATH_SURVIVED')\n" + "except OSError:\n" + " print('ABSPATH_DIED')\n" + "try:\n" + " inspect.stack()\n" + " print('STACK_SURVIVED')\n" + "except OSError:\n" + " print('STACK_DIED')\n" + ) + result = _run_child(body) + assert "ABSPATH_SURVIVED" in result.stdout, ( + "the ntpath emulation did not take — this pin proves nothing until " + f"abspath survives:\n{result.stdout}\n{result.stderr[-800:]}" + ) + assert "STACK_DIED" in result.stdout, ( + "with abspath surviving the getcwd denial, inspect.stack() was " + "expected to die at inspect.py:1009 — that causal link is the whole " + f"reason the Windows row of the table reads STACK_DIES:\n{result.stdout}" + ) + + +def test_world_b_does_convict_inspect_stack(): + """The other half of the same claim: world B is the one that convicts.""" + body = _WORLD_B + ( + "import inspect\n" + "try:\n" + " inspect.stack()\n" + " print('STACK_SURVIVED')\n" + "except OSError:\n" + " print('STACK_DIED')\n" + ) + result = _run_child(body) + assert "STACK_DIED" in result.stdout, ( + "world B no longer convicts inspect.stack — it is the only world that " + f"can, so this pin failing means the species is unmeasured:\n{result.stdout}" + ) + + +# --------------------------------------------------------------------------- +# THE PIN-SHAPE HOLE (mandatory AST ban) +# --------------------------------------------------------------------------- +# @trigger measured that the deleted second walk is UNREACHABLE from any +# import-shaped pin — apps/__init__.py always supplies a real-file frame, so +# restoring the defect leaves every behavioural test green (reproduced in +# hooks, drone, api and backup). Only a source-shape pin can catch it. +# It is an AST ban, never a string ban: the docstring above legitimately +# names inspect.stack while explaining the defect. + + +def _inspect_stack_calls(source: str) -> list: + """Return line numbers of every inspect.stack() CALL in source.""" + found = [] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.Call): + continue + func = node.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "stack" + and isinstance(func.value, ast.Name) + and func.value.id == "inspect" + ): + found.append(node.lineno) + return found + + +def test_guard_contains_no_inspect_stack_call(): + """BAN: the import guard must never call inspect.stack(). + + inspect builds a FrameInfo per frame and getmodule() calls + os.path.realpath outside any try (inspect.py:1009), so one call makes + every consumer's import depend on a readable cwd. + """ + offenders = _inspect_stack_calls(GUARD_FILE.read_text(encoding="utf-8")) + assert offenders == [], ( + f"inspect.stack() called at {GUARD_FILE}:{offenders} — walk frames " + "with sys._getframe instead; see this module's docstring." + ) + + +def test_ast_ban_convicts_a_real_call(): + """POSITIVE CONTROL: the ban detects the construct it bans.""" + assert _inspect_stack_calls("import inspect\nx = inspect.stack()\n") == [2] + + +def test_ast_ban_ignores_the_name_in_a_docstring(): + """NEGATIVE CONTROL: prose naming inspect.stack() is not a call. + + This is why the ban is an AST walk and not a grep — the guard's own + docstring explains the defect by name. + """ + source = '"""Do not call inspect.stack() here."""\nx = 1\n' + assert _inspect_stack_calls(source) == [] + + +def test_ast_ban_ignores_an_unrelated_stack_attribute(): + """NEGATIVE CONTROL: numpy.stack is a different function entirely.""" + source = "import numpy\ny = numpy.stack([1, 2])\n" + assert _inspect_stack_calls(source) == [] + + +# --------------------------------------------------------------------------- +# THE QUIET SPECIES (live-cwd pins) +# --------------------------------------------------------------------------- +# A crash is the loud half. The quiet half is a path that resolves FINE to the +# WRONG place: the import succeeds, every not-crash assertion above passes, and +# the branch writes wherever the shell happened to stand. Mutant M6 (helper +# returns Path.cwd() instead of the raw spelling) is killed by world A but +# SURVIVES world B, where getcwd still works — so the loud pins alone would +# have shipped it. These pins run from a foreign cwd and check the VALUE. +# +# This matters more here than elsewhere: _CANARY_ROOT feeds _JSON_DIR, which is +# the directory canary WRITES into. A cwd-derived value there means probe +# output lands wherever the caller stood, not in the branch. + + +@pytest.mark.parametrize("foreign_cwd", [tempfile.gettempdir(), str(Path.home())]) +def test_json_dir_is_branch_derived_not_cwd_derived(foreign_cwd): + """The handler's write destination must not follow the caller's cwd.""" + body = ( + "import aipass.canary.apps.handlers.json.json_handler as jh\n" + "print('JSON_DIR=' + str(jh._JSON_DIR))\n" + "print('ROOT=' + str(jh._CANARY_ROOT))\n" + ) + result = subprocess.run( + [sys.executable, "-c", body], + capture_output=True, + text=True, + timeout=120, + cwd=foreign_cwd, + ) + assert result.returncode == 0, result.stderr[-1500:] + reported = dict(line.split("=", 1) for line in result.stdout.strip().splitlines() if "=" in line) + assert reported["ROOT"] == str(BRANCH_ROOT), ( + f"_CANARY_ROOT followed the caller's cwd ({foreign_cwd}): got {reported['ROOT']}, expected {BRANCH_ROOT}" + ) + assert reported["JSON_DIR"] == str(BRANCH_ROOT / "canary_json"), ( + f"_JSON_DIR followed the caller's cwd ({foreign_cwd}): " + f"got {reported['JSON_DIR']} — canary would write its test data there" + ) + + +def test_module_file_returns_an_absolute_path_when_resolve_fails(): + """The fallback spelling is absolute, so callers can still take .parents. + + Since Python 3.9 __file__ is absolute, which is what makes returning the + raw spelling a correct answer rather than a degraded one. If that ever + stops holding, parents[3] silently indexes a shorter path. + """ + body = _WORLD_B + ( + "import aipass.canary.apps.handlers.paths as P\n" + "p = P.module_file(r'" + str(GUARD_FILE) + "')\n" + "print('ABS=' + str(p.is_absolute()))\n" + "print('VAL=' + str(p))\n" + ) + result = _run_child(body) + assert "ABS=True" in result.stdout, result.stdout + result.stderr[-1000:] + assert f"VAL={GUARD_FILE}" in result.stdout, result.stdout + + +# --------------------------------------------------------------------------- +# THE BEHAVIOURAL SIBLING for the caller-is-None branch +# --------------------------------------------------------------------------- +# CORRECTION TO THIS FILE'S ROUND-4 CLAIM. I reported that the AST ban was the +# ONLY instrument able to watch the deleted second walk. That was too strong, +# and @spawn measured the correction: the branch is unreachable from +# IMPORT-shaped pins (apps/__init__.py always supplies a real-file frame, so +# caller_file is never None on an import), but it IS reachable by calling +# _guard_branch_access() DIRECTLY from a python -c child — every frame is then +# string-pseudo or importlib, both skipped, so _find_real_caller returns None +# and the branch runs. Under a realpath denial a regrown walk dies there; the +# cured plain return survives. +# +# Both instruments are kept. The AST ban needs no subprocess and names the +# defect precisely; this one proves the cured branch actually behaves. + + +def test_caller_is_none_branch_survives_a_realpath_denial(): + """Call the guard directly, with no real-file frame, under world B. + + Two arming probes first, because this pin is worthless if the world is + not hostile or if the branch under test never ran: + probe 1 — the denial bites (realpath raises). + probe 2 — _find_real_caller() really returned None, so the guard took + the caller-is-None path and not some other early return. + + ON PROBE 2 AND ITS MUTANT, recorded rather than left as a gap. Deleting + probe 2 leaves this pin green (mutant M10 survives), because in TODAY's + world the caller genuinely is None — it is an equivalent mutant here. + It is not decorative: measured 2026-08-31, if _find_real_caller returns a + KIN path instead, the guard still returns — via the kin early-return, the + wrong reason entirely — and without probe 2 this pin passes vacuously + while the branch it names is never executed. Probe 2 is what makes the + pin's subject provable, so it stays. + """ + body = ( + "".join(f"import {m}\n" for m in PRELOAD) + + "import aipass.canary.apps.handlers as H\n" + + _WORLD_B + + "import os.path\n" + "try:\n" + " os.path.realpath('x')\n" + " print('ARM1_DENIAL_INERT')\n" + "except OSError:\n" + " print('ARM1_DENIAL_BITES')\n" + "print('ARM2_CALLER=' + repr(H._find_real_caller()[0]))\n" + "H._guard_branch_access()\n" + "print('GUARD_RETURNED')\n" + ) + result = _run_child(body) + assert "ARM1_DENIAL_BITES" in result.stdout, ( + f"world B was inert — this pin proves nothing:\n{result.stdout}\n{result.stderr[-800:]}" + ) + assert "ARM2_CALLER=None" in result.stdout, ( + f"_find_real_caller did not return None, so the caller-is-None branch was never exercised:\n{result.stdout}" + ) + assert "GUARD_RETURNED" in result.stdout, ( + "the guard raised on the caller-is-None path under a realpath denial " + f"— a frame walk has regrown there:\n{result.stdout}\n" + f"{result.stderr[-1200:]}" + ) diff --git a/src/aipass/cli/README.md b/src/aipass/cli/README.md index cb3670c28..cb728af0e 100644 --- a/src/aipass/cli/README.md +++ b/src/aipass/cli/README.md @@ -6,8 +6,8 @@ **Module:** `aipass.cli` **Version:** 2.1.0 **Seedgo:** 100% -**Tests:** 192 tests across 10 files — 201 passing, 0 skipped (parametrized cases expand at runtime) -**Last Updated:** 2026-08-25 +**Tests:** 201 tests across 11 files — 210 passing, 0 skipped (parametrized cases expand at runtime) +**Last Updated:** 2026-08-31 ## Quick Start @@ -120,7 +120,7 @@ cli/ │ │ └── templates/ # Scaffold placeholder │ ├── integrations/ # Scaffold placeholder │ └── plugins/ # Required by spawn builder template -├── tests/ # 192 tests across 10 files (201 pass, 0 skip) +├── tests/ # 201 tests across 11 files (210 pass, 0 skip) │ ├── conftest.py # make_capture_console() + strip_ansi() — the ONE capture helper │ ├── test_display.py # 60 tests — display functions + routing + exit codes + help flags │ ├── test_json_handler.py # 39 tests — CRUD, validation, rotation @@ -132,6 +132,7 @@ cli/ │ ├── test_integration.py # 6 tests — main() flow, entry points │ ├── test_init_provisioning.py # 4 tests — JSON provisioning on first run │ ├── test_parked_is_not_collected.py # 4 tests — collection barrier over tests/parked/ holds +│ ├── test_import_dead_cwd.py # 9 tests — imports survive a deleted cwd + AST ban on inspect.stack() │ └── parked/ # TRACKED, not run — collect_ignore_glob barrier (archive doctrine, 2026-08-18) ├── cli_json/ # Auto-created JSON (config, data, log) ├── logs/ # Branch-level logs @@ -200,7 +201,7 @@ json_handler.ensure_module_jsons("cli") # Create all 3 if missing --- -*Last Updated: 2026-08-25* +*Last Updated: 2026-08-31* --- [← Back to AIPass](../../../README.md) diff --git a/src/aipass/cli/apps/handlers/__init__.py b/src/aipass/cli/apps/handlers/__init__.py index bb8eee9a5..0bf8cbdd5 100755 --- a/src/aipass/cli/apps/handlers/__init__.py +++ b/src/aipass/cli/apps/handlers/__init__.py @@ -2,10 +2,12 @@ # META DATA HEADER # Name: handlers/__init__.py # Date: 2025-11-15 -# Version: 1.0.0 +# Version: 1.1.0 # Category: cli/handlers # # CHANGELOG (Max 5 entries): +# - v1.1.0 (2026-08-31): Dead-cwd cure — sys._getframe walk over inspect.stack(), +# linecache over code_context, guarded resolve, dead second walk deleted # - v1.0.0 (2025-11-15): Initial implementation - Public API # ============================================= @@ -18,10 +20,11 @@ from aipass.cli.apps.handlers import error_handler """ -import inspect +import linecache +import sys from pathlib import Path -__version__ = "1.0.0" +__version__ = "1.1.0" MY_BRANCH = "cli" @@ -36,27 +39,63 @@ def _find_real_caller(): - Frozen modules Returns tuple: (file_path, import_line) or (None, None) + + Walks frames with sys._getframe rather than inspect.stack(). MEASURED here + 2026-08-31 against the Windows shape (@trigger's report, @memory's finding on + the Windows CI gate): inspect.stack() needs a READABLE CWD, and it needs one + before any of this function's own code runs. It builds a FrameInfo per frame, + which reaches getsourcefile() -> getmodule() -> os.path.realpath(); ntpath's + realpath calls os.getcwd() UNCONDITIONALLY on its first lines, before it even + checks whether the path is absolute, and that call site in getmodule is not + inside a try. On POSIX the equivalent raise happens earlier, inside + getabsfile(), where inspect catches it — which is why this was invisible on + Linux for as long as it existed. A frame's co_filename is already a string in + memory; reading it touches the filesystem not at all. + + This guard runs at IMPORT time (bottom of this file), and most of the fleet + imports aipass.cli, so the blast radius of the old spelling was every + consumer of this branch, not just this branch. """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) + # Path.resolve() reaches the same ntpath.realpath, so this is guarded too — + # __file__ is already absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back + continue - for frame_info in stack: - filename = frame_info.filename + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename # Skip this file - if this_file in str(Path(filename).resolve()): + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - # Skip Python internals - if filename.startswith("<") or "importlib" in filename: - continue - - # Found a real file - try to get the import line + # Found a real file - try to get the import line. linecache is what + # inspect used for code_context; called directly it reads one named file + # and returns "" rather than raising when it cannot. import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -84,20 +123,19 @@ def _guard_branch_access(): import os if os.environ.get("AIPASS_DEBUG_GUARD"): - import sys - print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) if caller_file is None: - # Can't determine caller from real files - # Check if we're being run from command line (external) - # by looking at the raw stack for or - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return # Allow command-line Python through - return # Allow if truly can't determine + # No caller outside this file: an interactive session, a -c script, or an + # importlib-only stack. All three are allowed. This used to walk + # inspect.stack() again looking for / and then return + # either way — a second copy of the cwd dependency above, in service of a + # branch that could not change the answer. @prax's ruling, and @trigger + # measured the same dead arm in their own copy: a call that needs a + # working directory to compute a value nobody reads is pure exposure. + # Delete rather than wrap. + return # Check if caller is from our branch if f"/{MY_BRANCH}/" in caller_file.replace("\\", "/"): diff --git a/src/aipass/cli/apps/handlers/json/json_handler.py b/src/aipass/cli/apps/handlers/json/json_handler.py index 78b380657..5dc765584 100755 --- a/src/aipass/cli/apps/handlers/json/json_handler.py +++ b/src/aipass/cli/apps/handlers/json/json_handler.py @@ -15,10 +15,17 @@ from pathlib import Path from datetime import datetime from typing import Dict, Any, Optional -import inspect - -# Constants — resolved via __file__ (portable across any machine) -_BRANCH_ROOT = Path(__file__).resolve().parents[3] # json/ -> handlers/ -> apps/ -> cli/ +import sys + +# Constants — resolved via __file__ (portable across any machine). +# The resolve is guarded: it runs at IMPORT time, and Path.resolve() routes +# through ntpath.realpath on Windows, which reads os.getcwd() unconditionally. +# A process whose cwd was deleted cannot import this module otherwise, and most +# of the fleet imports it. __file__ is already absolute; resolve only normalises. +try: + _BRANCH_ROOT = Path(__file__).resolve().parents[3] # json/ -> handlers/ -> apps/ -> cli/ +except OSError: + _BRANCH_ROOT = Path(__file__).parents[3] _BRANCH_NAME = _BRANCH_ROOT.name JSON_DIR = _BRANCH_ROOT / f"{_BRANCH_NAME}_json" @@ -29,17 +36,27 @@ def _get_caller_module_name() -> str: Returns: Module name (e.g., "imports_standard" from imports_standard.py) + + Uses sys._getframe rather than inspect.stack() for the same reason the + handler guard does: inspect.stack() builds a FrameInfo per frame, which + reaches os.path.realpath() through getmodule(), and ntpath's realpath reads + os.getcwd() unconditionally. This one is CALL-time rather than import-time, + so it never blocked an import — but log_operation() is called from across the + fleet, and a branch logging an operation in a dead-cwd world would have died + here. Reading f_code.co_filename touches the filesystem not at all. """ - stack = inspect.stack() # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller - if len(stack) > 2: - caller_frame = stack[2] - caller_path = Path(caller_frame.filename) - module_name = caller_path.stem - - # Validate module name - if module_name and not module_name.startswith("_"): - return module_name + try: + caller_frame = sys._getframe(2) + except ValueError: + # Stack shallower than 3 frames — the old len(stack) > 2 guard. + return "unknown" + + module_name = Path(caller_frame.f_code.co_filename).stem + + # Validate module name + if module_name and not module_name.startswith("_"): + return module_name # Fallback return "unknown" diff --git a/src/aipass/cli/apps/modules/display.py b/src/aipass/cli/apps/modules/display.py index fb81c1500..0483facc6 100755 --- a/src/aipass/cli/apps/modules/display.py +++ b/src/aipass/cli/apps/modules/display.py @@ -235,7 +235,13 @@ def print_help(): CONSOLE.print("[bold cyan]REFERENCE:[/bold cyan]") CONSOLE.print() # RICH FORMATTING TIP: Use [yellow] for labels, [dim] for paths - _display_path = Path(__file__).resolve() + # Guarded: resolve() routes through ntpath.realpath on Windows, which reads + # os.getcwd() unconditionally. Call-time, not import-time — but printing help + # in a dead-cwd world should not traceback. __file__ is already absolute. + try: + _display_path = Path(__file__).resolve() + except OSError: + _display_path = Path(__file__) _cli_root = _display_path.parents[2] # display.py -> modules -> apps -> cli CONSOLE.print(f" [yellow]Module:[/yellow] [dim]{_display_path}[/dim]") CONSOLE.print(f" [yellow]Handlers:[/yellow] [dim]{_cli_root / 'apps' / 'handlers' / 'display'}[/dim]") diff --git a/src/aipass/cli/tests/test_import_dead_cwd.py b/src/aipass/cli/tests/test_import_dead_cwd.py new file mode 100644 index 000000000..ced2545f8 --- /dev/null +++ b/src/aipass/cli/tests/test_import_dead_cwd.py @@ -0,0 +1,394 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_import_dead_cwd.py - cli imports without a readable cwd +# Date: 2026-08-31 +# Version: 1.0.0 +# Category: cli/tests +# ============================================= + +"""Every cli module must import without a readable working directory. + +THE MECHANISM, measured on the Windows CI gate 2026-08-31 (@memory's finding, +reported to this branch by @trigger): ntpath.realpath calls os.getcwd() +UNCONDITIONALLY - not only for relative paths, the way posixpath does - and both +Path.resolve() and inspect.stack() route through it. So on Windows every one of +those reached AT IMPORT is a working-directory read, and a process whose cwd was +deleted cannot import the module at all. + +WHY THIS BRANCH, LOUDLY. The handler guard at apps/handlers/__init__.py runs at +IMPORT time, and most of the fleet imports aipass.cli. @trigger could not get +their own dead-cwd pin past this branch: they preload aipass.cli.apps.modules in +the HEALTHY world so their pin can measure their own sites, and marked that +preload TEMPORARY - delete when @cli is cured. @commons and @devpulse carry the +same note. This file is what retires those three preloads. + +THE WORLD injects ntpath's behaviour as a CONDITION rather than a platform, so +the defect is reachable from Linux. The injection happens in a child process +before any aipass import, so no module has cached the real functions. In-process +this property is unobservable - the imports already happened - which is why every +world here is a subprocess. + +WHY THE NTPATH SHAPE IS REQUIRED, and it cost @trigger an hour before they +warned us: on POSIX every route inspect.stack() takes to os.path.realpath runs +through getabsfile(), whose os.path.abspath raises FileNotFoundError for the +relative "" filenames an import stack carries - and +getmodule() CATCHES FileNotFoundError, so the unguarded +`modulesbyfile[os.path.realpath(f)]` below it is never reached. A pin built on a +realpath denial alone therefore goes GREEN against a reintroduced +inspect.stack(): it measures the module-level resolve() next door, not the stack +walk. ntpath has no such early raise. Emulated by giving abspath ntpath's +non-raising behaviour while realpath keeps reading cwd - the injection then +denies the call the DEFECT actually makes (@memory's rule), not one the platform +happens to catch first. +""" + +import ast +import subprocess +import sys +from pathlib import Path + +# Peers held constant in the HEALTHY world, before the denial, so a failure here +# names a cli site and never a dependency's. +_PRELOAD = r""" +import rich.console # noqa: F401 +import inspect # noqa: F401 +import linecache # noqa: F401 +""" + +_NTPATH_PREAMBLE = ( + _PRELOAD + + r""" +import os + +_real_realpath = os.path.realpath +_real_abspath = os.path.abspath + + +def _ntpath_realpath(path, **kw): + os.getcwd() # ntpath.realpath reads cwd before checking absoluteness + return _real_realpath(path, **kw) + + +def _ntpath_abspath(path): + # ntpath.abspath falls back rather than raising the way posixpath does. + try: + return _real_abspath(path) + except OSError: + return path + + +os.path.realpath = _ntpath_realpath +os.path.abspath = _ntpath_abspath + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd + +# Probe against the defect ITSELF, not a proxy: does inspect.stack() die in this +# world? If it does not, this pin proves nothing and says so. +import inspect + +try: + inspect.stack() + print("STACK_SURVIVES") +except FileNotFoundError: + print("STACK_DIES") +""" +) + +# Every cli site that resolved a path or walked the stack at IMPORT, named one +# per line so a failure says which module died rather than "the branch". +_SITES = r""" +import aipass.cli.apps.handlers # noqa: F401 +print("GUARD_OK") +import aipass.cli.apps.handlers.json.json_handler # noqa: F401 +print("JSON_OK") +import aipass.cli.apps.modules # noqa: F401 +print("MODULES_OK") +import aipass.cli # noqa: F401 +print("IMPORTED") +""" + +GUARD_WORLD = ( + _NTPATH_PREAMBLE + + r""" +import aipass.cli.apps.handlers # noqa: F401 + +print("IMPORTED") +""" +) + +SWEEP_WORLD = _NTPATH_PREAMBLE + _SITES + +# The CALL-time site: log_operation() -> _get_caller_module_name() walked the +# stack too. It never blocked an import, but log_operation is called from across +# the fleet and would have raised in this world. +# The guard's undeterminable-caller path, reached DIRECTLY. +# +# This one needs its own world and it is worth saying why, because the obvious +# pin does not cover it. During a real import there is always a real-file frame +# on the stack - apps/__init__.py does `from . import handlers` - so +# _find_real_caller() finds it and the caller_file-is-None branch is never +# entered. A mutation restoring the deleted second inspect.stack() walk therefore +# SURVIVES every import-shaped pin above (measured, 2026-08-31). The branch is +# only reachable by calling the guard directly, which is what a REPL or a -c +# script does - exactly the callers that branch existed to allow. +GUARD_NONE_PATH_WORLD = ( + _PRELOAD + + r""" +import os + +_real_realpath = os.path.realpath +_real_abspath = os.path.abspath + + +def _ntpath_realpath(path, **kw): + os.getcwd() + return _real_realpath(path, **kw) + + +def _ntpath_abspath(path): + try: + return _real_abspath(path) + except OSError: + return path + + +# Import in the HEALTHY world: this pin is about the guard being CALLED without +# a cwd, not about importing it without one - that is the sweep's job. +import aipass.cli.apps.handlers as h + +os.path.realpath = _ntpath_realpath +os.path.abspath = _ntpath_abspath + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd + +import inspect + +try: + inspect.stack() + print("STACK_SURVIVES") +except FileNotFoundError: + print("STACK_DIES") + +# From a -c script every frame is or internals, so this is the +# undeterminable-caller case by construction. +caller = h._find_real_caller() +print(f"CALLER_IS_NONE={caller == (None, None)}") +h._guard_branch_access() +print("IMPORTED") +""" +) + +CALL_TIME_WORLD = ( + _NTPATH_PREAMBLE + + r""" +from aipass.cli.apps.handlers.json import json_handler + +name = json_handler._get_caller_module_name() +print(f"CALLER_NAME={name}") + +# display.print_help() resolves __file__ to print the module reference. Also +# call-time, and `drone @cli --help` in a dead-cwd world should not traceback. +import io +import contextlib + +from aipass.cli.apps.modules import display + +with contextlib.redirect_stdout(io.StringIO()): + display.print_help() +print("HELP_OK") +print("IMPORTED") +""" +) + + +def _run(world: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", world], + capture_output=True, + text=True, + timeout=120, + ) + + +def _assert_world_is_hostile(out: str) -> None: + """The instrument must be able to fire, or every assertion below is vacuous.""" + assert "STACK_DIES" in out, ( + "inspect.stack() survived the ntpath-shaped denial - the instrument no " + f"longer reaches the defect and this pin proves nothing:\n{out}" + ) + + +class TestTheInstrumentItself: + """A positive control needs its own negative control (@spawn's rule).""" + + def test_the_denied_world_actually_kills_inspect_stack(self): + """If this goes green-by-survival, every other test in the file lies.""" + result = _run(_NTPATH_PREAMBLE + "\nprint('IMPORTED')\n") + _assert_world_is_hostile(result.stdout) + + def test_the_denied_world_is_not_so_hostile_that_everything_dies(self): + """The negative control: a plain stdlib import must still work. + + Without this, a world that broke ALL imports would satisfy every + 'did not crash' assertion by never getting far enough to crash. + """ + result = _run( + _NTPATH_PREAMBLE + + r""" +import json # noqa: F401 +import collections # noqa: F401 +print("STDLIB_OK") +print("IMPORTED") +""" + ) + assert "STDLIB_OK" in result.stdout, ( + "even plain stdlib imports die in this world - it is too hostile to " + f"prove anything about cli:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + +class TestImportTimeSites: + def test_handlers_guard_survives_the_ntpath_shaped_denial(self): + """The guard must not walk inspect.stack(). + + RED against the pre-2026-08-31 spelling: the guard ran at import and + died at inspect.py:1009 in getmodule, before any of its own code ran. + """ + result = _run(GUARD_WORLD) + _assert_world_is_hostile(result.stdout) + assert "IMPORTED" in result.stdout, ( + f"the handlers guard still depends on a readable cwd:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + def test_every_import_time_site_survives_a_denied_cwd(self): + """The whole branch, which is what the fleet actually imports.""" + result = _run(SWEEP_WORLD) + _assert_world_is_hostile(result.stdout) + assert "IMPORTED" in result.stdout, ( + f"a cli import died under the dead-cwd world:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + +class TestUndeterminableCallerPath: + """The branch that used to hold a SECOND inspect.stack() walk. + + Both its arms returned None with no side effect - match and fall-through + alike - so the call could not change the answer. @prax's ruling, and + @trigger measured the same dead arm in their own copy: a call that needs a + working directory to compute a value nobody reads is pure exposure. Deleted + rather than wrapped, and pinned here because no import-shaped test reaches it. + """ + + def test_guard_called_without_a_cwd_allows_an_undeterminable_caller(self): + result = _run(GUARD_NONE_PATH_WORLD) + _assert_world_is_hostile(result.stdout) + assert "CALLER_IS_NONE=True" in result.stdout, ( + "this world no longer produces an undeterminable caller, so it no " + f"longer exercises the deleted walk:\n{result.stdout}" + ) + assert "IMPORTED" in result.stdout, ( + "the guard's undeterminable-caller path still reads the cwd:\n" + f"stdout={result.stdout}\nstderr={result.stderr}" + ) + + +class TestCallTimeSites: + def test_log_operation_caller_detection_survives_a_denied_cwd(self): + """_get_caller_module_name walked inspect.stack() too. + + Call-time, so it never blocked an import - but every branch calls + json_handler.log_operation(), and this would have raised there. + """ + result = _run(CALL_TIME_WORLD) + _assert_world_is_hostile(result.stdout) + assert "IMPORTED" in result.stdout, ( + f"caller detection still depends on a readable cwd:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + assert "CALLER_NAME=" in result.stdout, f"caller detection did not return a name:\n{result.stdout}" + assert "HELP_OK" in result.stdout, ( + f"print_help() still depends on a readable cwd:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + +class TestStructuralSweep: + """AST ban on inspect.stack() in the files that run at import. + + REQUIRED BY THE ROLLOUT (@memory's rule, relayed by @devpulse): an + import-probe cannot see the caller-is-None branch, so a behavioural pin + alone can miss a reintroduced walk. Measured here and it is not theoretical + — a mutant restoring the deleted second walk survived every import-shaped + test in this file before TestUndeterminableCallerPath was added. + + Parsed, not grepped: a string ban convicts docstrings, and this file's own + docstrings discuss inspect.stack() at length. The two pins are + complementary, not redundant — this one convicts the CODE, the direct-call + world convicts the BEHAVIOUR. + """ + + # Files whose module level runs on import of this branch. + _IMPORT_TIME_FILES = ( + "apps/handlers/__init__.py", + "apps/handlers/json/json_handler.py", + "apps/modules/display.py", + ) + + def _branch_root(self) -> Path: + # tests/ -> cli/ + return Path(__file__).resolve().parents[1] + + def test_no_inspect_stack_call_in_import_time_files(self): + offenders = [] + for rel in self._IMPORT_TIME_FILES: + path = self._branch_root() / rel + assert path.exists(), f"pin names a file that does not exist: {rel}" + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Attribute) and func.attr == "stack": + if isinstance(func.value, ast.Name) and func.value.id == "inspect": + offenders.append(f"{rel}:{node.lineno}") + + assert not offenders, ( + "inspect.stack() is back in a file that runs at import time. It " + "needs a readable cwd before any of the calling function's own code " + "runs, so this crashes the import on Windows when the cwd is gone — " + "and most of the fleet imports this branch. Use sys._getframe over " + f"f_code.co_filename instead. Offenders: {offenders}" + ) + + def test_the_ast_pin_can_actually_fail(self): + """Positive control: the matcher must convict the shape it bans. + + Without this the test above is satisfied by a matcher that never + matches anything — which is how the old test_quality check scored + everyone 100. + """ + tree = ast.parse("import inspect\nstack = inspect.stack()\n") + found = [ + n + for n in ast.walk(tree) + if isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and n.func.attr == "stack" + and isinstance(n.func.value, ast.Name) + and n.func.value.id == "inspect" + ] + assert found, "the AST matcher cannot see inspect.stack() — the ban above is vacuous" + + def test_the_ast_pin_does_not_convict_a_docstring(self): + """A string ban would convict this very file's docstrings. Parsing does not.""" + tree = ast.parse('"""We used to call inspect.stack() here."""\nx = 1\n') + found = [n for n in ast.walk(tree) if isinstance(n, ast.Call)] + assert not found, "prose mentioning inspect.stack() must not count as a call" diff --git a/src/aipass/commons/apps/commons.py b/src/aipass/commons/apps/commons.py index 855ebb3fc..31ea05863 100644 --- a/src/aipass/commons/apps/commons.py +++ b/src/aipass/commons/apps/commons.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: commons.py # Description: Entry point CLI for drone @commons -# Version: 1.0.1 +# Version: 1.1.0 # Created: 2026-03-08 -# Modified: 2026-08-11 +# Modified: 2026-08-31 # ============================================= """ @@ -35,9 +35,33 @@ # Fix: When run as a script, Python adds apps/ to sys.path[0] which causes # this file (commons.py) to shadow the commons package. Remove it so the # installed package resolves correctly. -_script_dir = str(Path(__file__).resolve().parent) -if _script_dir in sys.path: - sys.path.remove(_script_dir) +# +# The .resolve() is LOAD-BEARING and measured, not decorative: through a +# symlinked checkout CPython sets sys.path[0] to the REAL directory while +# __file__ keeps the symlinked spelling, so the unresolved form alone would +# never match and the repair would silently not happen. Both spellings are +# removed, because the guarded world below can only offer the raw one. +# +# The guard is inline rather than through handlers.module_root because it runs +# BEFORE this repair - importing the commons package here is exactly what the +# repair exists to make safe. Same reason as everywhere else in this branch: +# on Windows ntpath.realpath reads os.getcwd() unconditionally, so a bare +# resolve() makes the entry point undefined in a process with a dead cwd. +_script_dirs = [str(Path(__file__).parent)] +try: + _script_dirs.append(str(Path(__file__).resolve().parent)) +except OSError as _exc: + # stderr, not the logger: @prax is imported below, AFTER this block, and + # importing it up here is what the repair exists to make safe. The + # condition is abnormal and belongs on the operator's screen rather than + # being swallowed - a shadowed package is a confusing failure to debug. + sys.stderr.write( + f"[commons] Cannot resolve {__file__} ({type(_exc).__name__}) — " + "sys.path repair limited to its unresolved spelling\n" + ) +for _script_dir in _script_dirs: + if _script_dir in sys.path: + sys.path.remove(_script_dir) # Handle broken pipe gracefully (e.g. output piped to head) if hasattr(signal, "SIGPIPE"): diff --git a/src/aipass/commons/apps/handlers/__init__.py b/src/aipass/commons/apps/handlers/__init__.py index 088183ddc..ea431abfc 100644 --- a/src/aipass/commons/apps/handlers/__init__.py +++ b/src/aipass/commons/apps/handlers/__init__.py @@ -2,33 +2,78 @@ # META DATA HEADER # Name: __init__.py - The Commons handlers package # Date: 2026-03-07 -# Version: 2.0.0 +# Version: 3.0.0 # Category: commons/apps/handlers # ============================================= """Commons handlers package - Security protected.""" -import inspect +import linecache +import os +import sys from pathlib import Path MY_BRANCH = "commons" # Commons is standalone, not under aipass.* def _find_real_caller(): - """Walk the stack to find the actual file that triggered this import.""" - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) - - for frame_info in stack: - filename = frame_info.filename - if this_file in str(Path(filename).resolve()): - continue + """Walk the stack to find the actual file that triggered this import. + + Skips this file, importlib internals, and frozen modules. + Returns tuple: (file_path, import_line) or (None, None). + + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on the + Windows CI gate 2026-08-31: inspect.stack() needs a READABLE CWD, and it + needs one before any of this function's own code runs. It builds a FrameInfo + per frame, which calls getsourcefile() -> getmodule() -> os.path.realpath(); + ntpath.realpath calls os.getcwd() unconditionally on its first lines, before + it even checks whether the path is absolute, and that call site in getmodule + is not inside a try. On POSIX the equivalent raise happens earlier, inside + getabsfile(), where inspect catches it - which is why this was invisible on + Linux for as long as it existed. A frame's co_filename is already a string + in memory; reading it touches nothing. + """ + # Path.resolve() reaches the same ntpath.realpath, so this is guarded too - + # __file__ is already absolute; the resolve only normalises it. Guarded + # inline rather than through handlers.module_root: this runs while the + # handlers package is still initialising, so importing a sibling handler + # here would re-enter the very import the guard is inspecting. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem - resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back + continue + + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing every consumer's import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename + + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() - return str(Path(filename).resolve()), import_line + + # linecache is what inspect used for code_context; called directly it + # reads one named file and returns "" rather than raising when it cannot. + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None + + return resolved, import_line + return None, None @@ -50,19 +95,16 @@ def _guard_branch_access(): """Block cross-branch handler imports.""" caller_file, import_line = _find_real_caller() - import os - if os.environ.get("AIPASS_DEBUG_GUARD"): - import sys - sys.stderr.write(f"[GUARD DEBUG] caller_file = {caller_file}\n") sys.stderr.write(f"[GUARD DEBUG] import_line = {import_line}\n") if caller_file is None: - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return # Allow command-line Python through + # No caller outside this file: an interactive session, a -c script, or an + # importlib-only stack. All three are allowed. This used to walk + # inspect.stack() again looking for / and then return + # either way - a second copy of the cwd dependency above, in service of a + # branch that could not change the answer. return # Check if caller is from within the commons directory. diff --git a/src/aipass/commons/apps/handlers/dashboard/dashboard_writer.py b/src/aipass/commons/apps/handlers/dashboard/dashboard_writer.py index 4aeb4a601..5008576ff 100644 --- a/src/aipass/commons/apps/handlers/dashboard/dashboard_writer.py +++ b/src/aipass/commons/apps/handlers/dashboard/dashboard_writer.py @@ -64,7 +64,7 @@ def _get_write_section() -> Optional[Callable[..., Any]]: from aipass.devpulse.apps.modules import dashboard as _dashboard # type: ignore[import-not-found] _write_section_fn = _dashboard.write_section - except ImportError: + except (ImportError, OSError): logger.warning("[dashboard_writer] devpulse import fallback") _write_section_fn = None return _write_section_fn diff --git a/src/aipass/commons/apps/handlers/database/db.py b/src/aipass/commons/apps/handlers/database/db.py index 7e5c365f5..1779e2cc9 100644 --- a/src/aipass/commons/apps/handlers/database/db.py +++ b/src/aipass/commons/apps/handlers/database/db.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: db.py # Description: The Commons SQLite connection manager -# Version: 1.0.0 +# Version: 1.1.0 # Created: 2026-03-07 -# Modified: 2026-03-07 +# Modified: 2026-08-31 # ============================================= """ @@ -27,6 +27,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger from aipass.commons.apps.handlers.json import json_handler +from aipass.commons.apps.handlers.module_root import module_file # ============================================================================= # DATABASE PATHS @@ -42,7 +43,9 @@ def _find_branch_root() -> Optional[Path]: Returns: Path to branch root (src/aipass/commons/), or None if not found. """ - current = Path(__file__).resolve().parent + # module_file() rather than a bare .resolve(): DB_PATH calls this at module + # scope, so on Windows a bare resolve reads cwd at import time. + current = module_file(__file__).parent for _ in range(10): if (current / ".trinity").is_dir(): return current @@ -386,8 +389,10 @@ def _find_branch_registry() -> Optional[Path]: if aipass_root: search_paths.append(Path(aipass_root) / "AIPASS_REGISTRY.json") - # Walk up from this package to find project root - current = Path(__file__).resolve().parent + # Walk up from this package to find project root. Not reached at import + # time, but it shares the helper: on a machine with an unreadable cwd the + # right answer is still this file's own absolute path, not an OSError. + current = module_file(__file__).parent for _ in range(10): candidate = current / "AIPASS_REGISTRY.json" if candidate.exists(): diff --git a/src/aipass/commons/apps/handlers/identity/identity_ops.py b/src/aipass/commons/apps/handlers/identity/identity_ops.py index ea19bcc30..c84184c9e 100644 --- a/src/aipass/commons/apps/handlers/identity/identity_ops.py +++ b/src/aipass/commons/apps/handlers/identity/identity_ops.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: identity_ops.py # Description: Identity operations handler -# Version: 1.1.0 +# Version: 1.2.0 # Created: 2026-03-07 -# Modified: 2026-08-07 +# Modified: 2026-08-31 # ============================================= """ @@ -28,6 +28,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger from aipass.commons.apps.handlers.json import json_handler +from aipass.commons.apps.handlers.module_root import module_file # ============================================================================= @@ -49,8 +50,10 @@ def _find_branch_registry_path() -> Path: if candidate.exists(): return candidate - # Walk up from this package to find project root - current = Path(__file__).resolve().parent + # Walk up from this package to find project root. module_file() rather + # than a bare .resolve(): BRANCH_REGISTRY_PATH calls this at module scope, + # so on Windows a bare resolve would read cwd at import time. + current = module_file(__file__).parent for _ in range(10): candidate = current / "AIPASS_REGISTRY.json" if candidate.exists(): @@ -118,6 +121,10 @@ def _find_caller_registries() -> List[Path]: for every miss. Results are sorted so a directory holding more than one registry always resolves the same way. + The suffix is re-checked case-sensitively after the glob: on Windows + and default macOS the pattern also matches *_registry.json, and a + lowercase counter would otherwise answer an identity lookup. + Returns: List of registry paths from the nearest matching directory, or []. """ @@ -137,7 +144,14 @@ def _find_caller_registries() -> List[Path]: matches = sorted( path for path in directory.glob("*_REGISTRY.json") - if path.name != "AIPASS_REGISTRY.json" and path.resolve() != primary + # Windows and default macOS are case-insensitive, so this glob also + # returns *_registry.json — plan counters and .template_registry.json + # (pathlib's * matches dotfiles). Re-check the SUFFIX case-sensitively; + # never the stem, because external projects name registries after + # themselves and vera_studio_REGISTRY.json is a real citizen. + if path.name.endswith("_REGISTRY.json") + and path.name != "AIPASS_REGISTRY.json" + and path.resolve() != primary ) if matches: return matches diff --git a/src/aipass/commons/apps/handlers/json/json_handler.py b/src/aipass/commons/apps/handlers/json/json_handler.py index d6d08ac59..05853f446 100644 --- a/src/aipass/commons/apps/handlers/json/json_handler.py +++ b/src/aipass/commons/apps/handlers/json/json_handler.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: json_handler.py # Description: JSON Auto-Creating Handler -# Version: 1.2.0 +# Version: 1.4.0 # Created: 2026-03-07 -# Modified: 2026-08-18 +# Modified: 2026-08-31 # ============================================= """ @@ -16,7 +16,7 @@ import json import os -import inspect +import sys import tempfile import time from datetime import datetime @@ -25,8 +25,13 @@ from aipass.prax.apps.modules.logger import system_logger as logger -# Constants - relative path resolution (pip-safe, no hardcoded absolutes) -_HANDLER_DIR = Path(__file__).resolve().parent # .../commons/apps/handlers/json/ +from aipass.commons.apps.handlers.module_root import module_file + +# Constants - relative path resolution (pip-safe, no hardcoded absolutes). +# module_file() rather than a bare .resolve(): this runs at IMPORT time, and on +# Windows ntpath.realpath reads os.getcwd() unconditionally, so a bare resolve +# here makes every commons import depend on a readable working directory. +_HANDLER_DIR = module_file(__file__).parent # .../commons/apps/handlers/json/ _APPS_DIR = _HANDLER_DIR.parent.parent # .../commons/apps/ _COMMONS_ROOT = _APPS_DIR.parent # .../commons/ BRANCH_JSON_DIR = str(_COMMONS_ROOT / "commons_json") @@ -107,16 +112,26 @@ def _get_caller_module_name() -> str: """ Auto-detect calling module name from call stack. + Walks with sys._getframe rather than inspect.stack() for the same reason + the handlers guard does (round 4, 2026-08-31): inspect.stack() builds a + FrameInfo for EVERY frame, and any frame whose filename is not on disk - + a "" entry point, a frozen importlib frame - sends it through + getmodule() to an os.path.realpath() that reads os.getcwd() + unconditionally on Windows. This function only ever wanted ONE frame's + co_filename, which is already a string in memory. + Returns: Module name (e.g., "imports_standard" from imports_standard.py) """ - stack = inspect.stack() - if len(stack) > 2: - caller_frame = stack[2] - caller_path = caller_frame.filename - module_name = os.path.splitext(os.path.basename(caller_path))[0] - if module_name and not module_name.startswith("_"): - return module_name + # Depth 2: this function (0), log_operation (1), the real caller (2) - + # matching the old stack[2]. ValueError where the old form found len <= 2. + try: + caller_path = sys._getframe(2).f_code.co_filename + except ValueError: + return "unknown" + module_name = os.path.splitext(os.path.basename(caller_path))[0] + if module_name and not module_name.startswith("_"): + return module_name return "unknown" diff --git a/src/aipass/commons/apps/handlers/module_root.py b/src/aipass/commons/apps/handlers/module_root.py new file mode 100644 index 000000000..117d7a1a2 --- /dev/null +++ b/src/aipass/commons/apps/handlers/module_root.py @@ -0,0 +1,81 @@ +# =================== AIPass ==================== +# Name: module_root.py +# Description: Resolve a module's __file__ without an import-time cwd read +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""One guarded spelling for module-level __file__ resolution. + +ntpath.realpath reads os.getcwd() UNCONDITIONALLY - not only for relative +paths, the way posixpath does - and Path.resolve() routes through it (measured +on the Windows CI gate 2026-08-31, @memory's finding). So on Windows every +Path(__file__).resolve() REACHED AT IMPORT is a working-directory dependency: +a process whose cwd was deleted cannot import the module. Commons carried +three such sites; they route through module_file() rather than each growing +its own try/except. + +Shape mirrors @devpulse's module_root and @memory's repo_root.module_file - +the ratified fleet cure - sized to this branch. The handlers package guard in +__init__.py keeps its own inline spelling on purpose: it runs BEFORE this +module can be imported, because importing it is what triggers the guard. +""" + +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +MODULE_NAME = "module_root" + + +def module_file(file: str) -> Path: + """A module's own path, absolute, resolved when the filesystem allows. + + .resolve() is still attempted, because normalising symlinks is why the + call is there and it succeeds on every healthy machine. The fallback is + only reached in the world where the alternative is a dead import, and it + is sound: __file__ has been absolute since Python 3.9, so the return is + the right file either way - just spelled through the symlink rather than + past it. + + Args: + file: A module's __file__. + + Returns: + The module's path, resolved if the filesystem could be asked. + """ + path = Path(file) + try: + return path.resolve() + except OSError as exc: + # debug, not warning: in the world that reaches this line every commons + # import takes it, and three identical warnings describe one condition - + # reported once, loudly, by whichever lane actually fails on it. + logger.debug(f"[{MODULE_NAME}] Cannot resolve {path} ({type(exc).__name__}) — using its absolute spelling") + _record_unresolved(path, exc) + return path + + +def _record_unresolved(path: Path, exc: OSError) -> None: + """Record the fallback without ever raising. + + Runs at module import time on all three callers; a diagnostic write that + fails in a bare world must not become the import crash this module exists + to prevent. json_handler is imported lazily because json_handler is itself + one of the three callers - a module-level import here would be a cycle. + + Args: + path: The __file__ that could not be resolved. + exc: The OSError resolve() raised. + """ + try: + from aipass.commons.apps.handlers.json import json_handler + + json_handler.log_operation( + "module_file_unresolved", + {"path": str(path), "error": f"{type(exc).__name__}: {exc}"}, + module_name=MODULE_NAME, + ) + except Exception as inner: # noqa: BLE001 - an audit line must never take an import down + logger.debug(f"[{MODULE_NAME}] fallback not recorded: {type(inner).__name__}: {inner}") diff --git a/src/aipass/commons/apps/modules/activity.py b/src/aipass/commons/apps/modules/activity.py index bd8e71854..aff4d0a5d 100644 --- a/src/aipass/commons/apps/modules/activity.py +++ b/src/aipass/commons/apps/modules/activity.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error -except ImportError: +except (ImportError, OSError): logger.warning("[activity] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/artifact.py b/src/aipass/commons/apps/modules/artifact.py index b51c2993a..ebdf852d1 100644 --- a/src/aipass/commons/apps/modules/artifact.py +++ b/src/aipass/commons/apps/modules/artifact.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error, success, warning -except ImportError: +except (ImportError, OSError): logger.warning("[artifact] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/capsule.py b/src/aipass/commons/apps/modules/capsule.py index bfb52ecdd..f33b0368c 100644 --- a/src/aipass/commons/apps/modules/capsule.py +++ b/src/aipass/commons/apps/modules/capsule.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error -except ImportError: +except (ImportError, OSError): logger.warning("[capsule] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/catchup.py b/src/aipass/commons/apps/modules/catchup.py index 5a83f0c51..74ac94adb 100644 --- a/src/aipass/commons/apps/modules/catchup.py +++ b/src/aipass/commons/apps/modules/catchup.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error -except ImportError: +except (ImportError, OSError): logger.warning("[catchup] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/central.py b/src/aipass/commons/apps/modules/central.py index a4efce443..bed050407 100644 --- a/src/aipass/commons/apps/modules/central.py +++ b/src/aipass/commons/apps/modules/central.py @@ -22,7 +22,7 @@ try: from aipass.cli.apps.modules import console, error, success -except ImportError: +except (ImportError, OSError): logger.warning("[central] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/comment.py b/src/aipass/commons/apps/modules/comment.py index 1299dcc87..3f1b95390 100644 --- a/src/aipass/commons/apps/modules/comment.py +++ b/src/aipass/commons/apps/modules/comment.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error, success -except ImportError: +except (ImportError, OSError): logger.warning("[comment] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/commons_identity.py b/src/aipass/commons/apps/modules/commons_identity.py index 92fa338d1..af49e82c5 100644 --- a/src/aipass/commons/apps/modules/commons_identity.py +++ b/src/aipass/commons/apps/modules/commons_identity.py @@ -24,7 +24,7 @@ try: from aipass.cli.apps.modules import console, error, warning -except ImportError: +except (ImportError, OSError): logger.warning("[commons_identity] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/database.py b/src/aipass/commons/apps/modules/database.py index a10070300..ed68c6bf1 100644 --- a/src/aipass/commons/apps/modules/database.py +++ b/src/aipass/commons/apps/modules/database.py @@ -22,7 +22,7 @@ try: from aipass.cli.apps.modules import console -except ImportError: +except (ImportError, OSError): logger.warning("[database] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/digest.py b/src/aipass/commons/apps/modules/digest.py index 4dbb7c901..2cf4da53a 100644 --- a/src/aipass/commons/apps/modules/digest.py +++ b/src/aipass/commons/apps/modules/digest.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error -except ImportError: +except (ImportError, OSError): logger.warning("[digest] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/engagement.py b/src/aipass/commons/apps/modules/engagement.py index d01ec7b13..84827409f 100644 --- a/src/aipass/commons/apps/modules/engagement.py +++ b/src/aipass/commons/apps/modules/engagement.py @@ -22,7 +22,7 @@ try: from aipass.cli.apps.modules import console, error, success -except ImportError: +except (ImportError, OSError): logger.warning("[engagement] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/explore.py b/src/aipass/commons/apps/modules/explore.py index bd4aefede..428eebe9c 100644 --- a/src/aipass/commons/apps/modules/explore.py +++ b/src/aipass/commons/apps/modules/explore.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error, success -except ImportError: +except (ImportError, OSError): logger.warning("[explore] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/feed.py b/src/aipass/commons/apps/modules/feed.py index 53e4771b8..c54f1901a 100644 --- a/src/aipass/commons/apps/modules/feed.py +++ b/src/aipass/commons/apps/modules/feed.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error -except ImportError: +except (ImportError, OSError): logger.warning("[feed] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/leaderboard.py b/src/aipass/commons/apps/modules/leaderboard.py index 466dd014d..f7b2d958b 100644 --- a/src/aipass/commons/apps/modules/leaderboard.py +++ b/src/aipass/commons/apps/modules/leaderboard.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error -except ImportError: +except (ImportError, OSError): logger.warning("[leaderboard] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/notification.py b/src/aipass/commons/apps/modules/notification.py index 6a754624f..12908adfc 100644 --- a/src/aipass/commons/apps/modules/notification.py +++ b/src/aipass/commons/apps/modules/notification.py @@ -22,7 +22,7 @@ try: from aipass.cli.apps.modules import console, error -except ImportError: +except (ImportError, OSError): logger.warning("[notification] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/post.py b/src/aipass/commons/apps/modules/post.py index 56bbce7d1..d48af19b8 100644 --- a/src/aipass/commons/apps/modules/post.py +++ b/src/aipass/commons/apps/modules/post.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error, success -except ImportError: +except (ImportError, OSError): logger.warning("[post] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/profile.py b/src/aipass/commons/apps/modules/profile.py index 76eeee1e2..dde10d435 100644 --- a/src/aipass/commons/apps/modules/profile.py +++ b/src/aipass/commons/apps/modules/profile.py @@ -22,7 +22,7 @@ try: from aipass.cli.apps.modules import console, error, success -except ImportError: +except (ImportError, OSError): logger.warning("[profile] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/reaction.py b/src/aipass/commons/apps/modules/reaction.py index 06893f422..d843693c2 100644 --- a/src/aipass/commons/apps/modules/reaction.py +++ b/src/aipass/commons/apps/modules/reaction.py @@ -22,7 +22,7 @@ try: from aipass.cli.apps.modules import console, error, success -except ImportError: +except (ImportError, OSError): logger.warning("[reaction] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/room.py b/src/aipass/commons/apps/modules/room.py index c6b5db8be..8d5a2a263 100644 --- a/src/aipass/commons/apps/modules/room.py +++ b/src/aipass/commons/apps/modules/room.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error, success -except ImportError: +except (ImportError, OSError): logger.warning("[room] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/search.py b/src/aipass/commons/apps/modules/search.py index 7cbcc3c5f..5c99b3693 100644 --- a/src/aipass/commons/apps/modules/search.py +++ b/src/aipass/commons/apps/modules/search.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error -except ImportError: +except (ImportError, OSError): logger.warning("[search] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/space.py b/src/aipass/commons/apps/modules/space.py index fd55da3b2..95de79747 100644 --- a/src/aipass/commons/apps/modules/space.py +++ b/src/aipass/commons/apps/modules/space.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error, success -except ImportError: +except (ImportError, OSError): logger.warning("[space] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/trade.py b/src/aipass/commons/apps/modules/trade.py index b3b670f6a..003754b80 100644 --- a/src/aipass/commons/apps/modules/trade.py +++ b/src/aipass/commons/apps/modules/trade.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error, warning -except ImportError: +except (ImportError, OSError): logger.warning("[trade] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/apps/modules/welcome.py b/src/aipass/commons/apps/modules/welcome.py index b8e585568..9a3d93892 100644 --- a/src/aipass/commons/apps/modules/welcome.py +++ b/src/aipass/commons/apps/modules/welcome.py @@ -21,7 +21,7 @@ try: from aipass.cli.apps.modules import console, error -except ImportError: +except (ImportError, OSError): logger.warning("[welcome] CLI console unavailable, using fallback") from rich.console import Console diff --git a/src/aipass/commons/tests/test_identity.py b/src/aipass/commons/tests/test_identity.py index 9f275dccb..02579b4c7 100644 --- a/src/aipass/commons/tests/test_identity.py +++ b/src/aipass/commons/tests/test_identity.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: test_identity.py # Description: Unit tests for identity module and identity_ops handler -# Version: 1.1.0 +# Version: 1.2.0 # Created: 2026-03-24 -# Modified: 2026-06-15 +# Modified: 2026-08-31 # ============================================= """ @@ -603,3 +603,258 @@ def test_branches_from_registry_malformed_json(tmp_path: Path): def test_branches_from_registry_missing_file(tmp_path: Path): """A registry path that doesn't exist yields no branches.""" assert _ops._branches_from_registry(tmp_path / "nope.json") == [] + + +# =========================================================================== +# Case-insensitive filesystem widening — *_REGISTRY.json glob (fleet defect) +# =========================================================================== +# +# On Windows and default macOS the filesystem is case-insensitive, so +# ``directory.glob("*_REGISTRY.json")`` also returns ``*_registry.json``. +# The repo is full of bait: plan counters under flow_json/ and +# ``.spawn/.template_registry.json`` — and pathlib's ``*`` matches dotfiles, +# unlike the stdlib glob module, so the dotted one is reachable too. +# +# These pins were red on Linux before the fix. They stay meaningful on Linux +# because the widening is emulated rather than assumed: the instrument wraps +# Path.glob to also yield the case-folded pattern's matches, and a negative +# control proves the decoy is invisible without it. + + +@pytest.fixture +def case_insensitive_glob(monkeypatch: pytest.MonkeyPatch): + """ + Emulate a case-insensitive filesystem for Path.glob. + + Yields the union of the pattern's matches and the case-folded + pattern's matches — which is what Windows hands back for a single + call. Wraps the real Path.glob rather than re-implementing listing, + so production's own call site is what gets widened. + """ + real_glob = Path.glob + + def widened(self, pattern, *args, **kwargs): + seen = {} + for found in real_glob(self, pattern, *args, **kwargs): + seen[str(found)] = found + folded = pattern.lower() + if folded != pattern: + for found in real_glob(self, folded, *args, **kwargs): + seen[str(found)] = found + return iter(sorted(seen.values())) + + monkeypatch.setattr(Path, "glob", widened) + + +def _plant_case_folded_decoy(project: Path, branch_dir: Path) -> Path: + """ + Plant a lowercase registry that maps branch_dir to the WRONG citizen. + + Named .template_registry.json for two reasons: it is real bait from + the fleet (@spawn writes one), and it sorts ahead of an uppercase + project registry — so if the filter fails, the decoy is what answers, + not merely what appears in a list. + """ + decoy = project / ".template_registry.json" + _write_registry( + decoy, + [{"name": "GHOST", "path": str(branch_dir), "email": "@ghost", "description": "should never resolve"}], + ) + return decoy + + +def test_case_folded_registry_cannot_answer_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, aipass_registry: Path, case_insensitive_glob +): + """ + On a case-insensitive filesystem the decoy is listed — it must not ANSWER. + + The assertion is about what identity resolution returns, not about set + membership, because the failure that matters is a citizen resolving to + the wrong name. + """ + project = tmp_path / "Vera-Studio" + project.mkdir() + branch_dir = _make_external_project(project) + _plant_case_folded_decoy(project, branch_dir) + monkeypatch.setenv("AIPASS_CALLER_CWD", str(branch_dir)) + + result = _id_mod.get_branch_info_from_registry(branch_dir) + + assert result is not None, "external citizen stopped resolving entirely" + assert result["name"] == "VERA", ( + f"case-folded decoy answered identity: got {result['name']!r} — " + "the *_REGISTRY.json glob widened on a case-insensitive filesystem" + ) + assert result["email"] == "@vera" + + +def test_case_folded_registry_excluded_from_caller_registries( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, case_insensitive_glob +): + """The decoy is filtered at the source, so no later matcher can reach it.""" + project = tmp_path / "Vera-Studio" + project.mkdir() + branch_dir = _make_external_project(project) + decoy = _plant_case_folded_decoy(project, branch_dir) + monkeypatch.setenv("AIPASS_CALLER_CWD", str(branch_dir)) + + found = _ops._find_caller_registries() + + assert decoy not in found, "case-folded registry survived the suffix filter" + assert [p.name for p in found] == ["VERA-STUDIO_REGISTRY.json"] + + +# The pattern production actually globs, and a probe file whose suffix is the +# lowercase form of it. Named constants because the DIRECTION is the claim - +# see test_the_host_probe_travels_the_defects_direction below. +_PRODUCTION_GLOB = "*_REGISTRY.json" +_PROBE_FILENAME = "hostprobe_registry.json" + + +def _host_folds_glob_case(tmp_path: Path) -> bool: + """ + Probe the host: does its glob fold case? Never assume, never skipif. + + The probe travels the DEFECT'S OWN DIRECTION (@ai_mail's round-4 lesson): + a lowercase file, matched against the uppercase pattern — which is exactly + what production asks the filesystem. Probing the other way round measures + a different question and can answer it differently. + + The probe lives in its own directory so it cannot collide with, or be + collided by, any registry a test planted. Its stem is deliberately + distinct: on a folding filesystem names differing only by case CANNOT + coexist (@memory), so a case-twin probe would overwrite a real file's + contents while the directory kept the original spelling. + """ + probe_dir = tmp_path / "_case_probe" + probe_dir.mkdir(exist_ok=True) + probe = probe_dir / _PROBE_FILENAME + probe.write_text("{}", encoding="utf-8") + try: + return probe in set(probe_dir.glob(_PRODUCTION_GLOB)) + finally: + probe.unlink() + + +def test_the_widening_instrument_actually_widens(tmp_path: Path, case_insensitive_glob): + """ + POSITIVE CONTROL — the instrument, exercised through production's own call. + + This makes the exact call ``_find_caller_registries`` makes rather than + re-stating its logic. If this fails, the pins above prove nothing and are + green for the wrong reason. + + On a folding host this control is satisfied by the filesystem itself + rather than by the instrument — which is not a defect but is worth + knowing. The negative control below names which world the run is in. + """ + project = tmp_path / "Vera-Studio" + project.mkdir() + branch_dir = _make_external_project(project) + decoy = _plant_case_folded_decoy(project, branch_dir) + + listed = list(project.glob("*_REGISTRY.json")) + + assert decoy in listed, "instrument did not widen — the case-insensitive emulation is broken" + assert project / "VERA-STUDIO_REGISTRY.json" in listed + + +def test_raw_glob_matches_what_the_host_filesystem_actually_does(tmp_path: Path): + """ + NEGATIVE CONTROL — without the instrument, the host's own answer. + + The earlier spelling of this test asserted the decoy is invisible, full + stop. That is true on Linux and FALSE on NTFS and default macOS — so the + control failed on the exact host the defect lives on, which is the one + place a control must not fail. Per @memory's ruling the host is PROBED, + never skipped, and both outcomes are pinned: + + - case-sensitive host: the decoy is invisible, so the pins above are + measuring the fix and not the filesystem — the instrument is load- + bearing and the positive control proves it widens. + - case-folding host: the decoy is listed for real. The widening is + native, the emulation is redundant, and the pins above are measuring + the defect's actual home. + + Either way the production filter is what must exclude it, and + ``test_case_folded_registry_excluded_from_caller_registries`` above + asserts that on both hosts. + """ + project = tmp_path / "Vera-Studio" + project.mkdir() + branch_dir = _make_external_project(project) + decoy = _plant_case_folded_decoy(project, branch_dir) + + listed = list(project.glob("*_REGISTRY.json")) + real_registry = project / "VERA-STUDIO_REGISTRY.json" + + if _host_folds_glob_case(tmp_path): + assert decoy in listed, ( + "the host folds glob case for a lowercase probe but not for the " + "decoy — the probe and the decoy disagree about the same filesystem" + ) + assert real_registry in listed + else: + assert decoy not in listed + assert listed == [real_registry] + + +def test_the_host_probe_travels_the_defects_direction(): + """ + The probe's DIRECTION is the claim, and this host cannot measure it. + + @ai_mail's round-4 lesson: the probe must ask the filesystem the same + question production asks — a lowercase file against the UPPERCASE + pattern. Reversed (an uppercase file against a lowercase pattern) it + measures a different question, and a host that folds only one way would + answer it differently. + + On a case-sensitive host both directions return False, so reversing the + probe changes no behavioural outcome here and no behavioural pin can + catch it. Stated honestly: this is a SHAPE pin, deliberately weaker than + the rest of this block. It exists so the direction cannot be silently + reversed by someone who does not know why it was chosen. + """ + assert _PRODUCTION_GLOB == "*_REGISTRY.json" + assert _PROBE_FILENAME.endswith("_registry.json"), ( + "the probe file must carry the LOWERCASE suffix — it is the decoy's " + "spelling, and the glob pattern is production's" + ) + assert _PRODUCTION_GLOB.lstrip("*") not in _PROBE_FILENAME, ( + "probe and pattern must differ in case, or nothing is being probed" + ) + + # The pattern is production's own, read from the source rather than + # re-typed: a pin on a copy of a constant survives the constant changing. + source = Path(_ops.__file__).read_text(encoding="utf-8") + assert f'directory.glob("{_PRODUCTION_GLOB}")' in source, ( + "production no longer globs this pattern — the host probe is asking a question nothing asks any more" + ) + + +def test_external_registries_with_lowercase_stems_still_resolve( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, aipass_registry: Path, case_insensitive_glob +): + """ + The filter is on the SUFFIX, never the stem. + + External projects name registries after themselves, so a lowercase or + mixed-case stem is legitimate — only the _REGISTRY.json suffix carries + the meaning. A stem-based filter would delete a real citizen. + """ + project = tmp_path / "vera_studio" + project.mkdir() + branch_dir = project / "src" / "app" + (branch_dir / ".trinity").mkdir(parents=True) + (branch_dir / ".trinity" / "passport.json").write_text("{}", encoding="utf-8") + _write_registry( + project / "vera_studio_REGISTRY.json", + [{"name": "APP", "path": "src/app", "email": "@app"}], + ) + monkeypatch.setenv("AIPASS_CALLER_CWD", str(branch_dir)) + + result = _id_mod.get_branch_info_from_registry(branch_dir) + + assert result is not None, "a lowercase-stem registry was wrongly filtered out" + assert result["email"] == "@app" diff --git a/src/aipass/commons/tests/test_import_dead_cwd.py b/src/aipass/commons/tests/test_import_dead_cwd.py new file mode 100644 index 000000000..192b1570e --- /dev/null +++ b/src/aipass/commons/tests/test_import_dead_cwd.py @@ -0,0 +1,968 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_import_dead_cwd.py - commons imports without a readable cwd +# Date: 2026-08-31 +# Version: 1.0.0 +# Category: commons/tests +# ============================================= + +"""Every commons module must import without a readable working directory. + +The mechanism, measured on the Windows CI gate 2026-08-31 (@memory's finding, +relayed round 4): ntpath.realpath calls os.getcwd() UNCONDITIONALLY - not only +for relative paths, the way posixpath does - and Path.resolve() routes through +it. So on Windows every Path(__file__).resolve() REACHED AT IMPORT is a +working-directory read, and a process whose cwd was deleted cannot import the +module at all. Commons carried four such sites (three module-level constants +plus the handlers guard, which reached the same call through inspect.stack()). + +The world injects ntpath's behaviour as a CONDITION rather than a platform: +os.path.realpath is wrapped to read os.getcwd() first, then os.getcwd is +denied. The injection happens in a child process before any aipass import, so +no module has cached the real functions. Other branches' import-time code is +held CONSTANT by preloading it in the healthy world - this pin measures +commons' own sites, not the fleet's rollout state. + +Where the interpreter's own pathlib never routes resolve() through +os.path.realpath (3.10 resolves absolute paths without touching cwd), the +denial cannot fire and the probe says so - the pin still asserts the imports +succeed there, it just proves less. Pinned as a probe with both outcomes, +never a skipif: the vacuous world is named in the output, and vacuity is +asserted to occur only on interpreters where it is the truth. +""" + +import ast +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +# The denial, and the probe that proves the denial can actually fire. +# Shared by every world below so no world can go quietly vacuous. +_PREAMBLE = r""" +# Other branches' import-time code is held CONSTANT: preloaded in the healthy +# world, before the denial. Their dead-cwd cure is their own build (fleet +# rollout in flight, 2026-08-31); this pin measures commons' sites only. +import aipass.prax # noqa: F401 +import aipass.prax.apps.modules.logger # noqa: F401 +import aipass.cli.apps.modules # noqa: F401 +import rich.console # noqa: F401 +import linecache # noqa: F401 + +import os + +_real_realpath = os.path.realpath + + +def _ntpath_condition(path, **kw): + os.getcwd() # ntpath.realpath reads cwd before checking absoluteness + return _real_realpath(path, **kw) + + +os.path.realpath = _ntpath_condition + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd + +# Probe the instrument: does THIS interpreter's resolve() reach the denied +# call for an absolute path? 3.11+ routes through os.path.realpath; 3.10 +# resolves absolute paths without cwd, so the denial cannot fire there. +import pathlib + +try: + pathlib.Path(pathlib.__file__).resolve() + print("PROBE_VACUOUS") +except FileNotFoundError: + print("PROBE_ARMED") +""" + +# The four sites, imported in one process. json_handler, identity_ops and db +# each build a module-level constant from Path(__file__).resolve(); reaching +# any of them also runs the package guard in apps/handlers/__init__.py. +SWEEP_WORLD = ( + _PREAMBLE + + r""" +import aipass.commons.apps.handlers.json.json_handler # noqa: F401 +import aipass.commons.apps.handlers.identity.identity_ops # noqa: F401 +import aipass.commons.apps.handlers.database.db # noqa: F401 +import aipass.commons.apps.commons # noqa: F401 + +print("IMPORTED") +""" +) + +# The guard alone. Named separately because its cwd read arrives by a +# different route - inspect.stack() builds a FrameInfo per frame and +# getmodule() calls os.path.realpath() outside any try - so a cure to the +# constants would leave this red and a reader deserves to know which broke. +GUARD_WORLD = ( + _PREAMBLE + + r""" +import aipass.commons.apps.handlers # noqa: F401 + +print("IMPORTED") +""" +) + + +# The same denial, shaped like ntpath rather than posixpath. +# +# NEEDED because the preamble above cannot reach the guard's OLD defect. On +# POSIX every route inspect.stack() takes to os.path.realpath runs through +# getabsfile(), whose os.path.abspath raises FileNotFoundError for the +# relative "" filenames an import stack carries - +# and getmodule() CATCHES FileNotFoundError, so the unguarded +# `modulesbyfile[os.path.realpath(f)]` line below it is never reached. The +# first draft of this pin went green against a reintroduced inspect.stack() +# for exactly that reason: it was measuring the module-level resolve() next +# door, not the stack walk. +# +# ntpath does not have that early raise, so on Windows getmodule() proceeds +# and dies on the unguarded realpath. Emulated here by giving abspath ntpath's +# non-raising behaviour while realpath keeps reading cwd - the injection then +# denies the call the DEFECT actually makes (@memory's rule), instead of one +# the platform happens to catch first. +_NTPATH_PREAMBLE = r""" +import aipass.prax # noqa: F401 +import aipass.prax.apps.modules.logger # noqa: F401 +import aipass.cli.apps.modules # noqa: F401 +import rich.console # noqa: F401 +import inspect # noqa: F401 +import linecache # noqa: F401 + +import os + +_real_realpath = os.path.realpath +_real_abspath = os.path.abspath + + +def _ntpath_realpath(path, **kw): + os.getcwd() # ntpath.realpath reads cwd before checking absoluteness + return _real_realpath(path, **kw) + + +def _ntpath_abspath(path): + # ntpath.abspath falls back rather than raising the way posixpath does. + try: + return _real_abspath(path) + except OSError: + return path + + +os.path.realpath = _ntpath_realpath +os.path.abspath = _ntpath_abspath + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd + +# Probe the instrument against the defect ITSELF, not against a proxy: does +# inspect.stack() die in this world? If it does not, this pin proves nothing. +try: + inspect.stack() + print("STACK_SURVIVES") +except FileNotFoundError: + print("STACK_DIES") +""" + +# The guard, under the world its old implementation actually died in. +NTPATH_GUARD_WORLD = ( + _NTPATH_PREAMBLE + + r""" +import aipass.commons.apps.handlers # noqa: F401 + +print("IMPORTED") +""" +) + + +def _run(world: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", world], + capture_output=True, + text=True, + timeout=60, + ) + + +def _assert_probe_armed(out: str) -> None: + """The instrument must be able to fire, or the pin proves nothing.""" + if "PROBE_VACUOUS" in out: + # Allowed only where it is the interpreter's truth (pre-3.11 pathlib + # never routes an absolute resolve through os.path.realpath). + assert sys.version_info < (3, 11), ( + "resolve() survived the denial on an interpreter that routes " + "through os.path.realpath - the instrument is broken, not the world" + ) + else: + assert "PROBE_ARMED" in out, f"probe printed neither outcome:\n{out}" + + +def test_handler_modules_import_with_dead_cwd(): + """The three module-level resolve() sites, under a denied cwd.""" + result = _run(SWEEP_WORLD) + out = result.stdout + + assert "IMPORTED" in out, f"import died under the dead-cwd world:\nstdout={out}\nstderr={result.stderr}" + _assert_probe_armed(out) + + +def test_handlers_guard_imports_with_dead_cwd(): + """The package guard reaches the same cwd read through its stack walk.""" + result = _run(GUARD_WORLD) + out = result.stdout + + assert "IMPORTED" in out, f"the handlers guard died under the dead-cwd world:\nstdout={out}\nstderr={result.stderr}" + _assert_probe_armed(out) + + +# =========================================================================== +# An uncured peer raises OSError at import, not ImportError +# =========================================================================== +# +# @prax's round-4 finding: while the fleet dead-cwd rollout is in flight, a +# peer branch whose guard is still uncured raises FileNotFoundError from its +# own import - an OSError, which `except ImportError` does not catch. Commons +# has 23 optional cross-branch imports (22 on @cli, 1 on @devpulse); every one +# of them would have propagated a peer's OSError and killed its own consumer. +# +# The peer is denied by a meta_path finder rather than by a dead cwd, because +# the claim is about the EXCEPTION TYPE crossing the branch boundary, not +# about how the peer came to raise it. + +_DENY_PEER = r""" +import importlib.abc +import sys + +DENIED = "{denied}" + + +class _UncuredPeer(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == DENIED or fullname.startswith(DENIED + "."): + raise FileNotFoundError(2, "uncured peer guard", "") + return None + + +sys.meta_path.insert(0, _UncuredPeer()) + +# Prove the denial can fire before relying on it. +try: + __import__(DENIED) + print("DENIAL_VACUOUS") +except FileNotFoundError: + print("DENIAL_ARMED") +except ImportError: + print("DENIAL_WRONG_TYPE") +""" + + +def _peer_world(denied: str, body: str) -> str: + return _DENY_PEER.format(denied=denied) + body + + +def test_modules_import_when_cli_peer_raises_oserror(): + """A module keeps its own fallback console when @cli cannot be imported.""" + world = _peer_world( + "aipass.cli.apps.modules", + r""" +import aipass.commons.apps.modules.post # noqa: F401 +import aipass.commons.apps.modules.feed # noqa: F401 +import aipass.commons.apps.modules.database # noqa: F401 + +print("IMPORTED") +""", + ) + result = _run(world) + out = result.stdout + + assert "DENIAL_ARMED" in out, f"the peer denial never fired - the pin proves nothing:\n{out}" + assert "IMPORTED" in out, ( + f"an OSError from an uncured peer escaped `except ImportError`:\nstdout={out}\nstderr={result.stderr}" + ) + + +def test_dashboard_writer_survives_an_oserror_from_devpulse(): + """The lazy @devpulse import degrades to None rather than raising.""" + world = _peer_world( + "aipass.devpulse.apps.modules", + r""" +from aipass.commons.apps.handlers.dashboard import dashboard_writer + +assert dashboard_writer._get_write_section() is None, "expected the unavailable-peer fallback" + +print("IMPORTED") +""", + ) + result = _run(world) + out = result.stdout + + assert "DENIAL_ARMED" in out, f"the peer denial never fired - the pin proves nothing:\n{out}" + assert "IMPORTED" in out, ( + f"an OSError from an uncured peer escaped `except ImportError`:\nstdout={out}\nstderr={result.stderr}" + ) + + +def test_handlers_guard_survives_the_ntpath_shaped_denial(): + """ + The Item-2 pin proper: the guard must not walk inspect.stack(). + + Separate from the world above because that one is satisfied by the + module-level resolve() guard alone. This one is red against an + inspect.stack() walk and green against the sys._getframe cure. + """ + result = _run(NTPATH_GUARD_WORLD) + out = result.stdout + + assert "STACK_DIES" in out, ( + "inspect.stack() survived the ntpath-shaped denial - the instrument no " + f"longer reaches the defect and this pin is vacuous:\n{out}" + ) + assert "IMPORTED" in out, ( + f"the handlers guard still depends on a readable cwd:\nstdout={out}\nstderr={result.stderr}" + ) + + +# =========================================================================== +# The entry point's sys.path repair +# =========================================================================== +# +# MEASURED 2026-08-31, and the measurement reversed the obvious conclusion. +# The first read of apps/commons.py said the .resolve() there was doing no +# work - __file__ has been absolute since 3.9, so why normalise it? - and the +# tidy cure for a Windows cwd read is to not make the call at all. +# +# Then it was measured through a symlink ON LINUX: CPython sets sys.path[0] to +# the REAL script directory while __file__ keeps the SYMLINKED spelling. Drop +# the resolve and the repair silently stops happening on a symlinked checkout, +# and commons.py shadows the commons package again. +# +# ROUND 5 CORRECTION. The first version of this pin asserted `sys.path[0] == +# resolved` flat, on every platform, and it went RED on the Windows runner +# (28ee90d5, run 33431848734). The production cure was never wrong - commons.py +# removes BOTH spellings, so it does not care which one the host uses - but the +# PIN had turned one platform's measurement into a universal law. +# +# SPECIES VERDICT, derived rather than guessed: this is NOT drone's +# recipe-not-state case. That pin's own structure answers it - symlink +# creation is wrapped in a try that SKIPS, so a runner lacking the privilege +# would have produced a skip. CI produced a FAILURE, therefore the world was +# BUILT on Windows and only the assertion was POSIX-shaped. No marker is +# needed; the expectation table below is. +# +# Split per @canary: CAUSE (two spellings exist), OUTCOME (sys.path[0] is one +# of the two), LINK (commons.py removes both), so a future red names its own +# mechanism instead of pointing at a compound assertion. + + +# What sys.path[0] equals for a script invoked through a symlinked directory. +# "resolved" - the real directory, symlink followed +# "unresolved" - the spelling the invocation used +# None - NOT MEASURED LIVE on this platform yet +# +# posix: measured live here, 2026-08-31, and re-measured on every run. +# nt: the round-5 CI red IS the Windows measurement, and it is a NEGATIVE +# one - it proves sys.path[0] is not unconditionally "resolved" there, +# not which spelling it is. Left None deliberately. It becomes a live +# measurement the first time this file runs green on Windows and +# somebody reads the recorded values out of the outcome pin's message. +_SYS_PATH0_SPELLING = { + "posix": "resolved", + "nt": None, +} + + +def _spellings_matching(path0: str, unresolved: str, resolved: str) -> list: + """Which of the two spellings apps/commons.py removes does sys.path[0] equal? + + Extracted from the pin that uses it so the answer is testable with + SYNTHETIC values. The interesting cases - a third spelling, or a host + where both spellings coincide - are unreachable on any single platform, + and a judgement only exercised through a live probe is a judgement only + one platform ever checks. That is the same unreachable-branch species + round 5 exists to close, one level up. + """ + spellings = {"resolved": resolved, "unresolved": unresolved} + return sorted(name for name, value in spellings.items() if value == path0) + + +def _assert_matches_table(platform: str, matched: list, path0: str = "") -> None: + """Hold a run against the recorded expectation for its platform. + + A None entry means "measured negatively, value unknown" - the nt case - + and asserts nothing beyond what the outcome pin already did. Extracted so + the DISAGREEING case is reachable: on any single platform the live probe + always agrees with its own table row, so an assertion only ever fed live + values is an assertion that never fails and never can. + """ + expected = _SYS_PATH0_SPELLING[platform] + if expected is None: + return + assert expected in matched, ( + f"on {platform} sys.path[0] was recorded as {expected!r} but this run " + f"matched {matched} — the table is now wrong, and the repair may be " + f"relying on a spelling the host stopped using. sys.path[0]={path0!r}" + ) + + +def _assert_probe_is_usable(probe: dict) -> None: + """Judge a probe result. Synthetic-testable for the same reason as above.""" + if not probe["built"]: + assert probe["reason"], "the world failed to build and did not say why" + assert os.name == "nt", ( + f"a POSIX host could not build a symlink world: {probe['reason']} — " + "that is a broken machine, not a platform difference" + ) + else: + assert probe["path0"] and probe["unresolved"] and probe["resolved"], ( + f"the probe reported success with an empty field: {probe}" + ) + + +def _assert_caller_is_none_world(out: str, stderr: str) -> None: + """Judge the caller-is-None world's output. Both arming probes enforced.""" + assert "STACK_DIES" in out, f"arming probe 1: the ntpath world did not bite:\n{out}\n{stderr}" + assert "CALLER_IS_NONE" in out, ( + "arming probe 2: the world did not reach the caller-is-None branch, so " + f"a regrown walk there would go unwatched:\n{out}" + ) + assert "GUARD_RETURNED" in out, ( + f"the guard raised instead of returning for a caller-less stack:\nstdout={out}\nstderr={stderr}" + ) + + +def _symlink_spelling_probe(tmp_path) -> dict: + """Run a script through a symlinked directory and record what the host did. + + Returns a dict rather than skipping, so "this host cannot build the world" + is DATA every platform asserts against instead of a silent absence. + """ + real = tmp_path / "real" + real.mkdir() + (real / "probe.py").write_text( + "import sys\nfrom pathlib import Path\n" + "print(sys.path[0])\nprint(str(Path(__file__).parent))\n" + "print(str(Path(__file__).resolve().parent))\n", + encoding="utf-8", + ) + link = tmp_path / "link" + try: + link.symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError) as exc: # Windows without the privilege + return {"built": False, "reason": f"{type(exc).__name__}: {exc}"} + + result = subprocess.run( + [sys.executable, str(link / "probe.py")], + capture_output=True, + text=True, + timeout=60, + ) + lines = result.stdout.strip().splitlines() + if len(lines) != 3: + return {"built": False, "reason": f"probe produced {len(lines)} lines: {result.stdout!r} {result.stderr!r}"} + + path0, unresolved, resolved = lines + return {"built": True, "reason": "", "path0": path0, "unresolved": unresolved, "resolved": resolved} + + +def test_this_platform_has_a_recorded_sys_path0_expectation(): + """ + A platform nobody has measured must not pass by being unlisted. + + The table carries None for "measured negatively, value unknown" - that is + an entry. A MISSING key is the thing this refuses. + """ + # Both platforms the fleet actually runs on must carry an entry, or a + # mutant deleting the one this host does not use goes unnoticed. + assert {"posix", "nt"} <= set(_SYS_PATH0_SPELLING), ( + f"the expectation table lost a known platform: {sorted(_SYS_PATH0_SPELLING)}" + ) + assert os.name in _SYS_PATH0_SPELLING, ( + f"no recorded expectation for os.name={os.name!r} — measure it and add " + "it to _SYS_PATH0_SPELLING rather than letting this file assert a " + "POSIX fact on an unmeasured platform, which is exactly the round-5 red" + ) + + +def test_symlink_world_buildability_is_recorded(tmp_path): + """ + CONTROL — the skip made observable. + + The old pin swallowed an unbuildable world into pytest.skip, where it read + as "nothing to see". Here it is an assertion either way. + """ + _assert_probe_is_usable(_symlink_spelling_probe(tmp_path)) + + +def test_cause_a_symlinked_invocation_yields_two_spellings(tmp_path): + """ + CAUSE — the fact that makes commons.py's .resolve() load-bearing at all. + + If __file__ and its resolved form were always the same string, removing + one spelling would be enough and the resolve really would be dead weight. + """ + probe = _symlink_spelling_probe(tmp_path) + if not probe["built"]: + return # buildability is asserted by its own pin above + + assert probe["unresolved"] != probe["resolved"], ( + "the symlink was not exercised - this run measured nothing. " + f"unresolved={probe['unresolved']!r} resolved={probe['resolved']!r}" + ) + + +def test_outcome_sys_path0_is_one_of_the_two_spellings_commons_removes(tmp_path): + """ + OUTCOME — the claim production actually depends on, on every platform. + + commons.py removes BOTH spellings, so it does not need to know which one + the host chose; it only needs the host to choose one of them. This is what + the flat `== resolved` assertion should have said. The message records all + three values, so a Windows red arrives already diagnosed. + """ + probe = _symlink_spelling_probe(tmp_path) + if not probe["built"]: + return + + matched = _spellings_matching(probe["path0"], probe["unresolved"], probe["resolved"]) + + assert matched, ( + "sys.path[0] is a THIRD spelling that apps/commons.py does not remove, " + "so the shadowing repair silently does nothing here.\n" + f" os.name = {os.name}\n" + f" sys.path[0] = {probe['path0']!r}\n" + f" unresolved = {probe['unresolved']!r}\n" + f" resolved = {probe['resolved']!r}" + ) + + _assert_matches_table(os.name, matched, probe["path0"]) + + +def test_link_commons_removes_both_spellings(tmp_path): + """ + LINK — the pin that actually protects the code, and the one that CANNOT + fail for platform reasons. + + Whatever any host does with sys.path[0], the repair is correct as long as + commons.py computes and removes both spellings. Source-level on purpose: + the round-5 red came from proving this through a subprocess when it did + not need a subprocess at all. + """ + entry = Path(__file__).resolve().parent.parent / "apps" / "commons.py" + source = entry.read_text(encoding="utf-8") + + assert "_script_dirs = [str(Path(__file__).parent)]" in source, ( + "the UNRESOLVED spelling is no longer collected — a host whose " + "sys.path[0] keeps the invocation spelling is no longer repaired" + ) + assert "_script_dirs.append(str(Path(__file__).resolve().parent))" in source, ( + "the RESOLVED spelling is no longer collected — this is the deletion the symlink measurement exists to prevent" + ) + assert "sys.path.remove(_script_dir)" in source, "neither spelling is being removed from sys.path" + + +# =========================================================================== +# json_handler's caller auto-detect - the second inspect.stack() in this tree +# =========================================================================== +# +# Found while answering the round-4 follow-up, not dispatched: _get_caller_module_name +# walked inspect.stack() to read ONE frame's filename. Same species as the guard, +# and live on every log_operation() call that does not pass module_name (which is +# most of them - the auto-detect is what names each module's log file). +# +# The world below is shaped so BOTH halves of the claim are measurable at once: +# - a "" entry frame, so the ntpath denial can actually fire (a frame +# whose filename exists on disk short-circuits getsourcefile and the +# instrument goes quiet - measured, see the harness note in the reply); +# - a REAL compiled probe_caller.py as the frame the lookup reads, so the pin +# asserts the ANSWER and not merely the absence of a crash (@aipass's rule: +# if a probe compiles a caller frame, the frame doing the lookup must BE +# the compiled one). + +_CALLER_PROBE_WORLD = r""" +import os +import sys + +probe_dir = {probe_dir!r} + +# Written BEFORE the denial: a genuine module on disk, so the frame the lookup +# reads is compiled source and not a "" pseudo-name. +with open(os.path.join(probe_dir, "probe_caller.py"), "w", encoding="utf-8") as fh: + fh.write( + "from aipass.commons.apps.handlers.json import json_handler\n" + "def _inner():\n" + " return json_handler._get_caller_module_name()\n" + "def ask():\n" + " return _inner()\n" + ) +sys.path.insert(0, probe_dir) + +import aipass.prax # noqa: F401 +import aipass.prax.apps.modules.logger # noqa: F401 +import aipass.cli.apps.modules # noqa: F401 +import rich.console # noqa: F401 +import inspect +import linecache # noqa: F401 +import probe_caller + +_real_realpath = os.path.realpath +_real_abspath = os.path.abspath + + +def _ntpath_realpath(path, **kw): + os.getcwd() + return _real_realpath(path, **kw) + + +def _ntpath_abspath(path): + try: + return _real_abspath(path) + except OSError: + return path + + +os.path.realpath = _ntpath_realpath +os.path.abspath = _ntpath_abspath + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd + +try: + inspect.stack() + print("STACK_SURVIVES") +except FileNotFoundError: + print("STACK_DIES") + +try: + print("NAME:" + probe_caller.ask()) +except OSError as exc: + print("RAISED:" + type(exc).__name__) +""" + + +def test_caller_module_autodetect_survives_and_answers_under_a_dead_cwd(tmp_path): + """log_operation's module auto-detect must name the caller, not raise.""" + result = _run(_CALLER_PROBE_WORLD.format(probe_dir=str(tmp_path))) + out = result.stdout + + assert "STACK_DIES" in out, f"the ntpath world did not arm - this pin would pass vacuously:\n{out}\n{result.stderr}" + assert "NAME:probe_caller" in out, ( + "the auto-detect either raised or named the wrong module under a dead " + f"cwd:\nstdout={out}\nstderr={result.stderr}" + ) + + +# =========================================================================== +# Structural ban: no inspect.stack() anywhere in apps/ +# =========================================================================== +# +# @devpulse's round-4 follow-up, measured fleet-wide by @trigger and reproduced +# in eight branches: the guard's DELETED second walk sat in the caller-is-None +# branch, which no import-shaped pin can reach - apps/__init__ always supplies +# a real-file frame, so that branch never runs during an import. Regrow the walk +# and every behavioural pin above stays green. +# +# A parse-tree ban, never a spelling ban: this file and the guard both NAME +# inspect.stack in prose while explaining the defect, and a string search would +# convict its own documentation. + + +def _inspect_stack_call_lines(source: str, filename: str) -> list: + """Line numbers of every `inspect.stack(...)` CALL in source. + + An ast.Call whose func is Attribute 'stack' on Name 'inspect'. Prose, + imports and any other .stack are invisible to it by construction. + """ + tree = ast.parse(source, filename=filename) + hits = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "stack" + and isinstance(func.value, ast.Name) + and func.value.id == "inspect" + ): + hits.append(node.lineno) + return hits + + +def _apps_modules() -> list: + """Every production .py under apps/, excluding the parked pre-refactor tree.""" + apps = Path(__file__).resolve().parent.parent / "apps" + return sorted(p for p in apps.rglob("*.py") if ".archive" not in p.parts) + + +def test_no_inspect_stack_call_anywhere_in_apps(): + """ + Tree-wide, because after round 4 there are zero legitimate callers left. + + The guard was cured by dispatch; json_handler's caller auto-detect was + found and cured in the same session. Nothing in apps/ needs a FrameInfo + per frame, so the ban costs nothing and closes the unreachable branch. + """ + modules = _apps_modules() + + # Walk-actually-parsed control: a blinded walk that finds no files would + # otherwise read as a clean tree. + assert len(modules) > 60, f"the walk found only {len(modules)} modules — it is not seeing apps/" + + offenders = {} + for path in modules: + source = path.read_text(encoding="utf-8") + try: + lines = _inspect_stack_call_lines(source, str(path)) + except SyntaxError as exc: + # Ignorance is not evidence of cleanliness. + raise AssertionError(f"{path} could not be parsed, so it cannot be cleared: {exc}") from exc + if lines: + offenders[str(path.relative_to(path.parents[2]))] = lines + + assert not offenders, ( + f"inspect.stack() is back in apps/: {offenders}. It builds a FrameInfo " + "per frame and reads os.getcwd() on Windows; use sys._getframe." + ) + + +def test_the_guard_specifically_carries_no_stack_walk(): + """ + Named separately from the tree-wide sweep so the failure reads plainly. + + The guard's second walk lived in the caller-is-None branch, which no + import-shaped pin in this file can reach. + """ + guard = Path(__file__).resolve().parent.parent / "apps" / "handlers" / "__init__.py" + source = guard.read_text(encoding="utf-8") + + assert "inspect" in source, ( + "the guard no longer mentions inspect at all — if the explanation was " + "deleted with the call, this pin's positive control is worth re-reading" + ) + assert _inspect_stack_call_lines(source, str(guard)) == [], ( + "the handlers guard walks inspect.stack() again — the caller-is-None " + "branch is invisible to every behavioural pin in this file" + ) + + +# --- controls, all through the REAL matcher, never a re-implementation ------- + + +def test_the_matcher_convicts_a_planted_call_at_the_right_line(): + """POSITIVE CONTROL — without this the ban above proves nothing.""" + source = "import inspect\n\n\ndef f():\n return inspect.stack()\n" + + assert _inspect_stack_call_lines(source, "") == [5] + + +def test_the_matcher_ignores_prose_naming_inspect_stack(): + """ + A spelling ban would convict this file's own docstrings, and the guard's. + """ + source = '"""We do not use inspect.stack() here."""\n# inspect.stack() is banned\nx = 1\n' + + assert _inspect_stack_call_lines(source, "") == [] + + +@pytest.mark.parametrize( + "source, why", + [ + ("import numpy\nnumpy.stack([1, 2])\n", "a different module's stack()"), + ("import traceback\ntraceback.stack()\n", "traceback, not inspect"), + ("self.stack()\n", "an attribute on self"), + ("import inspect\ninspect.currentframe()\n", "currentframe is sys._getframe by another name"), + ("import inspect\nstack = inspect.stack\n", "a reference, not a call"), + ], +) +def test_the_matcher_leaves_legitimate_code_alone(source, why): + """ + NEGATIVE CONTROLS — a matcher that convicts these would be unusable. + + inspect.currentframe() is deliberately legal: it is sys._getframe under + another name and touches no filesystem. + """ + assert _inspect_stack_call_lines(source, "") == [], f"matcher over-convicted: {why}" + + +# =========================================================================== +# The caller-is-None branch, reached behaviourally +# =========================================================================== +# +# @devpulse's round-4 correction (relay 6be10723), measured by @spawn: the +# guidance that only an AST ban can watch this branch was TOO STRONG. It is +# unreachable from IMPORT-shaped pins - apps/__init__ always supplies a +# real-file frame, so _find_real_caller never returns None during an import, +# which is the nine-branch reproduction - but calling the guard DIRECTLY from +# a python -c child reaches it: every frame is a string pseudo-name or an +# importlib internal, both skipped, so the walk falls off the end. +# +# Kept ALONGSIDE the AST ban rather than replacing it: the ban needs no +# subprocess and names the defect precisely, this one proves the branch still +# behaves. Both die to a regrown walk, which is the point. + +_CALLER_IS_NONE_WORLD = ( + _NTPATH_PREAMBLE + + r""" +import aipass.commons.apps.handlers as guard + +# ARMING PROBE 2: the world must actually exercise the caller-is-None branch. +# Without this the pin could pass by taking the same-branch return instead, +# and a regrown walk in the dead branch would never be touched. +caller_file, _ = guard._find_real_caller() +print("CALLER_IS_NONE" if caller_file is None else "CALLER_WAS:" + str(caller_file)) + +# The branch itself: no caller outside this file, so the guard must simply +# return. A regrown inspect.stack() walk dies here under the denial. +guard._guard_branch_access() +print("GUARD_RETURNED") +""" +) + + +def test_guard_returns_from_the_caller_is_none_branch_under_a_dead_cwd(): + """The branch no import-shaped pin in this file can reach.""" + result = _run(_CALLER_IS_NONE_WORLD) + + _assert_caller_is_none_world(result.stdout, result.stderr) + + +# =========================================================================== +# The judgements above, exercised with SYNTHETIC input +# =========================================================================== +# +# Round-5 mutant finding, in my own new pins: four mutants SURVIVED the first +# pass and every one of them mutated a branch Linux never executes - the nt +# table entry, the third-spelling case, the unbuildable-world case, an arming +# probe that never fires here. A judgement reached only through a live probe +# is a judgement exactly one platform ever checks, which is the same +# unreachable-branch species this round exists to close, one level up. +# +# So the judgements are called directly with made-up values. No subprocess, no +# symlink, no platform - every branch reachable everywhere. + + +@pytest.mark.parametrize( + "path0, unresolved, resolved, expected, why", + [ + ("/real", "/link", "/real", ["resolved"], "POSIX through a symlink — measured live"), + ("/link", "/link", "/real", ["unresolved"], "a host that keeps the invocation spelling"), + ("/real", "/real", "/real", ["resolved", "unresolved"], "no symlink: both spellings coincide"), + ("/third", "/link", "/real", [], "a spelling commons.py does not remove — the repair is dead here"), + ], +) +def test_the_spelling_matcher_answers_each_world(path0, unresolved, resolved, expected, why): + """ + The third-spelling row is the one that matters and the one no platform + reaches: it is the only input for which the outcome pin must FAIL. + """ + assert _spellings_matching(path0, unresolved, resolved) == expected, why + + +def test_an_unbuildable_world_must_state_a_reason(): + """The unbuildable branch, reachable on a host that can build symlinks.""" + with pytest.raises(AssertionError, match="did not say why"): + _assert_probe_is_usable({"built": False, "reason": ""}) + + +def test_a_probe_reporting_success_with_an_empty_field_is_refused(): + """Success plus a blank value would make the outcome pin compare nothing.""" + with pytest.raises(AssertionError, match="empty field"): + _assert_probe_is_usable({"built": True, "reason": "", "path0": "", "unresolved": "/a", "resolved": "/b"}) + + +def test_a_usable_probe_passes_the_same_judgement(): + """POSITIVE CONTROL — the refusals above are not refusing everything.""" + _assert_probe_is_usable({"built": True, "reason": "", "path0": "/real", "unresolved": "/link", "resolved": "/real"}) + + +@pytest.mark.parametrize( + "out, missing", + [ + ("CALLER_IS_NONE\nGUARD_RETURNED\n", "arming probe 1"), + ("STACK_DIES\nGUARD_RETURNED\n", "arming probe 2"), + ("STACK_DIES\nCALLER_IS_NONE\n", "the guard raised"), + ], +) +def test_the_caller_is_none_judgement_needs_every_marker(out, missing): + """ + Each marker is load-bearing: drop any one and the world proves less. + + Reachable here because the output is a string I hand it, not something a + live child process happened to print. + """ + with pytest.raises(AssertionError, match=missing): + _assert_caller_is_none_world(out, "") + + +def test_the_caller_is_none_judgement_passes_a_complete_world(): + """POSITIVE CONTROL for the three refusals above.""" + _assert_caller_is_none_world("STACK_DIES\nCALLER_IS_NONE\nGUARD_RETURNED\n", "") + + +@pytest.mark.parametrize( + "platform, matched, raises, why", + [ + ("posix", ["resolved"], False, "the recorded POSIX answer"), + ("posix", ["resolved", "unresolved"], False, "both spellings coincide — still contains the record"), + ("posix", ["unresolved"], True, "POSIX stopped using the resolved spelling — the table is stale"), + ("posix", [], True, "a third spelling — the record cannot be satisfied"), + ("nt", ["unresolved"], False, "nt is recorded None: unknown, so nothing to contradict"), + ("nt", [], False, "nt asserts nothing here; the outcome pin already refused this"), + ], +) +def test_the_table_crosscheck_only_fires_where_something_was_recorded(platform, matched, raises, why): + """ + The disagreeing rows are unreachable from any live probe. + + A live run on a given platform always agrees with its own row, so this + check could be deleted without a single test noticing until a host + changed behaviour years from now. Fed synthetically it has real teeth. + """ + if raises: + with pytest.raises(AssertionError, match="the table is now wrong"): + _assert_matches_table(platform, matched) + else: + _assert_matches_table(platform, matched) + + +def test_the_outcome_pin_actually_calls_the_table_crosscheck(): + """ + STRUCTURAL — the call site, which no behavioural pin can reach. + + _assert_matches_table has real teeth now (fed synthetically it refuses a + stale record), but DELETING ITS CALL from the outcome pin killed nothing: + a live run on a healthy host always agrees with its own table row, so the + call's absence is invisible. Same shape as the inspect.stack ban - when + the branch cannot be executed, assert on the parse tree instead. + """ + source = Path(__file__).read_text(encoding="utf-8") + tree = ast.parse(source, filename=__file__) + + target = "test_outcome_sys_path0_is_one_of_the_two_spellings_commons_removes" + functions = [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name == target] + assert len(functions) == 1, f"expected exactly one {target}, found {len(functions)}" + + called = { + node.func.id + for node in ast.walk(functions[0]) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + assert "_assert_matches_table" in called, ( + "the outcome pin no longer holds its run against _SYS_PATH0_SPELLING, " + "so the recorded per-platform expectation is decorative" + ) + assert "_spellings_matching" in called, "the outcome pin no longer uses the matcher it is built on" diff --git a/src/aipass/commons/tests/test_module_root.py b/src/aipass/commons/tests/test_module_root.py new file mode 100644 index 000000000..116de0eb4 --- /dev/null +++ b/src/aipass/commons/tests/test_module_root.py @@ -0,0 +1,122 @@ +# =================== AIPass ==================== +# Name: test_module_root.py +# Description: Pins the guarded __file__ resolver +# Version: 1.1.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""module_file() must return the RIGHT file when resolve() cannot be asked. + +test_import_dead_cwd.py proves the imports survive. That is a weaker claim +than it looks: a fallback returning Path(".") would also let every import +succeed, and every caller would then walk up from the wrong place. These pins +are about the VALUE, not the survival. + +The denial is scoped to ONE path rather than to Path.resolve as a whole. The +first draft patched the method globally, which was green under the branch +pytest.ini and RecursionError under the repo-root conftest: that conftest's +write guard resolves a path inside log_operation, so a blanket denial sent +_record_unresolved back through it forever. A global patch of a stdlib method +is a claim about every caller in the process, and this test only has a claim +about one file. +""" + +from pathlib import Path + +import pytest + +from aipass.commons.apps.handlers import module_root + +_DEAD_CWD = FileNotFoundError(2, "cwd deleted", "") + + +def _deny_resolve_for(monkeypatch: pytest.MonkeyPatch, target: str) -> None: + """Make resolve() raise for exactly one path; everything else is untouched.""" + real_resolve = Path.resolve + + def _maybe_denied(self, *args, **kwargs): + if str(self) == target: + raise _DEAD_CWD + return real_resolve(self, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", _maybe_denied) + + +@pytest.fixture +def silent_audit(monkeypatch: pytest.MonkeyPatch) -> list: + """Capture _record_unresolved's audit line instead of writing it to disk.""" + calls: list = [] + + def _capture(operation, data=None, module_name=None): + calls.append((operation, data, module_name)) + return True + + monkeypatch.setattr( + "aipass.commons.apps.handlers.json.json_handler.log_operation", + _capture, + ) + return calls + + +def test_module_file_resolves_normally(): + """On a healthy filesystem the answer is the resolved path.""" + assert module_root.module_file(__file__) == Path(__file__).resolve() + + +def test_module_file_returns_the_absolute_file_when_resolve_is_denied( + monkeypatch: pytest.MonkeyPatch, silent_audit: list +): + """ + The fallback is the module's own absolute path, not the cwd and not '.'. + + __file__ has been absolute since Python 3.9, so the fallback names the + same file resolve() would have named - just spelled through any symlink + rather than past it. + """ + _deny_resolve_for(monkeypatch, __file__) + + result = module_root.module_file(__file__) + + assert result == Path(__file__) + assert result.is_absolute(), "the fallback returned a relative path — every caller would walk from the cwd" + assert result.name == "test_module_root.py" + assert [c[0] for c in silent_audit] == ["module_file_unresolved"], "the fallback was taken but never recorded" + + +def test_the_denial_instrument_denies_one_path_and_only_that_path(monkeypatch: pytest.MonkeyPatch): + """ + CONTROL — a monkeypatch that silently failed to bind would make the pin + above green for the wrong reason (module_file would simply have resolved + normally, and Path(__file__) == Path(__file__).resolve() on a machine with + no symlink in the path). + + The second half is the control ON the control: the denial must be narrow, + or it is patching the whole process again. + """ + _deny_resolve_for(monkeypatch, __file__) + + with pytest.raises(FileNotFoundError): + Path(__file__).resolve() + + sibling = Path(__file__).parent / "conftest.py" + assert sibling.resolve() == Path(sibling).absolute(), "the denial leaked past its one target path" + + +def test_a_failing_audit_write_never_escapes(monkeypatch: pytest.MonkeyPatch): + """ + _record_unresolved runs at module import time on every caller. If it could + raise, the diagnostic would become the import crash module_file exists to + prevent. + """ + + def _exploding_log(*args, **kwargs): + raise RuntimeError("the audit lane is down") + + _deny_resolve_for(monkeypatch, __file__) + monkeypatch.setattr( + "aipass.commons.apps.handlers.json.json_handler.log_operation", + _exploding_log, + ) + + assert module_root.module_file(__file__) == Path(__file__) diff --git a/src/aipass/conftest.py b/src/aipass/conftest.py index 0e37126a6..7c9455143 100644 --- a/src/aipass/conftest.py +++ b/src/aipass/conftest.py @@ -1,3 +1,10 @@ +"""Fleet-level pytest conftest: keeps handler-embedded test fixtures out of collection. + +Template test files shipped inside apps/handlers/ are fixture material for the +branches' own checkers, not tests of this repo — collecting them would run them +against whichever branch pytest happens to root in. +""" + import os collect_ignore_glob = [ diff --git a/src/aipass/daemon/README.md b/src/aipass/daemon/README.md index 7a39a01b9..3629dfc46 100644 --- a/src/aipass/daemon/README.md +++ b/src/aipass/daemon/README.md @@ -6,7 +6,7 @@ **Module:** `aipass.daemon` **Created:** 2026-03-07 **Citizen Class:** aipass_framework -**Last Updated:** 2026-08-25 +**Last Updated:** 2026-08-30 --- @@ -216,6 +216,46 @@ The trust model is asymmetric on purpose. A passport can never *add* scope (noth - `fresh` (bool) — start a fresh Claude session (true) or resume (false) - `model` (string, optional) — `"haiku"` or `"sonnet"` recommended for light wakes +### What a fire consumes (2026-08-30) + +**Only a wake that actually STARTED consumes the job's period.** A fire ends in one +of three states, not two, and each writes a different record: + +| Outcome | What happened | `last_run` | Next tick | +|---------|---------------|-----------|-----------| +| `fired` | An agent started | stamped | period consumed | +| `blocked` | The wake was refused before anything started — the target is busy (`lock`), holds an interactive session (`occupancy`), autonomous_pause is on, or the dispatch lock could not be taken | **untouched** | still due; retries inside the same window after a 5-min hold | +| `failed` | The wake ran and went wrong, or the target is a decided refusal (`resolve` — no such branch; `blocklist`) | stamped | measured from the last SUCCESS; 10-min backoff | + +The middle row is the fix for a scheduler that planted the blocker for its own next +fire: an interactive room left open in a branch made `wake_branch` refuse, the refusal +was recorded as a run, and the next day's fire was swallowed by a room nobody was +sitting in. Blocked is not ran. + +Both holds are **bounds, not suppressions**. Removing a suppression without adding a +bound is how you turn one swallowed fire into a spawn storm: a windowed schedule allows +±15 min at a ~2-minute tick, and an interval job measures from its last *attempt*, which +a block deliberately does not write. Blocked holds for less than a failure (5 min vs 10) +because nothing spawned and the target being busy usually clears itself. + +`queue` renders the new `blocked` value in its existing `last_status` column — the +`--json` schema is unchanged. + +### The scheduled lane + +Every wake `run` makes was fired by a clock, so it passes `scheduled=True` to +`wake_branch` unconditionally. The flag describes **this caller's lane, never the +target** — deciding it per-target would mean reading the target's passport here, a +second copy of the manager gate `wake_branch` already owns. + +What it changes: a **manager** target goes headless through `dispatch_monitor` +(self-terminating, context pin, bounce mail, lock cleanup, a register entry something +closes) instead of an interactive tmux session that nothing ever closes — which was the +room that blocked the next night's fire. A **`WAKE_BLOCKLIST`** target (`@devpulse`) is +refused outright in this lane; `@devpulse/cl-harvest-resume` is the one job that would +meet that fence, and it ships disabled. Every other target is unaffected. +`rotation.py` already took this lane for managers; `run.py` was the odd path out. + ### Staggering No native offset field. To stagger jobs, seed different `last_run` values in `daemon_json/daemon_runstate.json`. Within a single tick, jobs that fire together are already separated by a fixed 1s sleep (`run.py`) — that is not configurable and is not a substitute for offsetting the schedules themselves. @@ -368,11 +408,11 @@ remaining import is from an archived file. Scheduling is now decentralized: each ## Test Suite -- **454 tests** across 19 test files +- **559 tests** across 23 test files - 10/10 modules covered, 46/50 public functions tested - Seedgo audit: **100%** with bypasses, **99%** with the bypass list emptied (22 entries) -*Last Updated: 2026-08-25* +*Last Updated: 2026-08-30* --- [← Back to AIPass](../../../README.md) diff --git a/src/aipass/daemon/apps/daemon_wakeup.py b/src/aipass/daemon/apps/daemon_wakeup.py index 1d10f1e7c..14017b3cf 100644 --- a/src/aipass/daemon/apps/daemon_wakeup.py +++ b/src/aipass/daemon/apps/daemon_wakeup.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: daemon_wakeup.py # Description: DAEMON Wake-Up Cron Trigger -# Version: 1.0.0 +# Version: 1.1.0 # Created: 2026-02-15 -# Modified: 2026-03-10 +# Modified: 2026-08-31 # ============================================= """ @@ -25,7 +25,6 @@ import os import sys import json -from pathlib import Path from datetime import datetime if sys.platform == "win32": @@ -38,6 +37,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger from aipass.cli.apps.modules import console from aipass.daemon.apps.handlers.json import json_handler +from aipass.daemon.apps.handlers.module_root import module_file try: import fcntl @@ -49,7 +49,7 @@ # CONSTANTS # ============================================= -_DAEMON_ROOT = Path(__file__).resolve().parents[1] # src/aipass/daemon/ +_DAEMON_ROOT = module_file(__file__).parents[1] # src/aipass/daemon/ JSON_DIR = _DAEMON_ROOT / "daemon_json" LOCK_FILE = JSON_DIR / "wakeup.lock" diff --git a/src/aipass/daemon/apps/handlers/__init__.py b/src/aipass/daemon/apps/handlers/__init__.py index 451253823..0e882cf1b 100644 --- a/src/aipass/daemon/apps/handlers/__init__.py +++ b/src/aipass/daemon/apps/handlers/__init__.py @@ -1,6 +1,7 @@ """Daemon handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "aipass.daemon" @@ -10,33 +11,60 @@ def _find_real_caller(): """ Walk the stack to find the actual file that triggered this import. - Skips: - - This file (handlers/__init__.py) - - Python's importlib internals - - Frozen modules - - Returns tuple: (file_path, import_line) or (None, None) + Skips this file, importlib internals, and frozen modules. + Returns tuple: (file_path, import_line) or (None, None). + + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on + the Windows CI gate 2026-08-31: inspect.stack() needs a READABLE CWD, and + it needs one before any of this function's own code runs. It builds a + FrameInfo per frame, which calls getsourcefile() -> getmodule() -> + os.path.realpath; ntpath.realpath calls the working-directory reader + unconditionally on its first lines, before it even checks whether the path + is absolute, and that call site in getmodule is not inside a try. On POSIX + the equivalent raise happens earlier, inside getabsfile(), where inspect + catches it - which is why this was invisible on Linux for as long as it + existed. A frame's co_filename is already a string in memory; reading it + touches nothing. """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) + # Path.resolve() reaches the same ntpath.realpath, so this is guarded too - + # __file__ is already absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back + continue - for frame_info in stack: - filename = frame_info.filename + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename # Skip this file - if this_file in str(Path(filename).resolve()): + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - # Skip Python internals - if filename.startswith("<") or "importlib" in filename: - continue - - # Found a real file - try to get the import line - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + # Found a real file - try to get the import line. linecache is what + # inspect used for code_context; called directly it reads one named file + # and returns "" rather than raising when it cannot. + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -60,24 +88,19 @@ def _guard_branch_access(): """ caller_file, import_line = _find_real_caller() - # DEBUG: Print what we found import os if os.environ.get("AIPASS_DEBUG_GUARD"): - import sys - sys.stderr.write(f"[GUARD DEBUG] caller_file = {caller_file}\n") sys.stderr.write(f"[GUARD DEBUG] import_line = {import_line}\n") if caller_file is None: - # Can't determine caller from real files - # Check if we're being run from command line (external) - # by looking at the raw stack for or - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return # Allow command-line Python through - return # Allow if truly can't determine + # No caller outside this file: an interactive session, a -c script, or + # an importlib-only stack. All three are allowed. This used to walk + # inspect.stack() AGAIN looking for / and then return + # either way - a second copy of the cwd dependency above, in service of + # a branch that could not change the answer. + return # Check if caller is from our branch # MY_BRANCH is "aipass.daemon" (dotted), but filesystem uses "/aipass/daemon/" diff --git a/src/aipass/daemon/apps/handlers/json/json_handler.py b/src/aipass/daemon/apps/handlers/json/json_handler.py index e97129c28..882c98c01 100644 --- a/src/aipass/daemon/apps/handlers/json/json_handler.py +++ b/src/aipass/daemon/apps/handlers/json/json_handler.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: json_handler.py # Description: JSON Auto-Creating Handler -# Version: 1.4.0 +# Version: 1.6.0 # Created: 2025-11-21 -# Modified: 2026-08-18 +# Modified: 2026-08-31 # ============================================= """ @@ -20,7 +20,6 @@ from pathlib import Path from datetime import datetime from typing import Dict, List, Any, Optional -import inspect if sys.platform == "win32": os.environ.setdefault("PYTHONUTF8", "1") @@ -30,13 +29,65 @@ _reconfigure(encoding="utf-8", errors="replace") from aipass.prax import logger +from aipass.daemon.apps.handlers.module_root import module_file # Constants -_DAEMON_ROOT = Path(__file__).resolve().parents[3] # src/aipass/daemon/ -JSON_DIR = _DAEMON_ROOT / "daemon_json" +_DAEMON_ROOT = module_file(__file__).parents[3] # src/aipass/daemon/ + +# The real directory, kept under its own name so the resolver below can tell an +# explicit test patch from the untouched default. Recomputed on a module +# reload exactly like JSON_DIR is, which is what makes the comparison survive +# one (see _current_json_dir). +_IMPORT_TIME_JSON_DIR = _DAEMON_ROOT / "daemon_json" + +# Still a module attribute: ~20 existing tests redirect by monkeypatching this, +# and the fleet contract (@prax, mail 01fb09c6) keeps that door open on purpose. +JSON_DIR = _IMPORT_TIME_JSON_DIR + +# The fleet-wide test redirect, @trigger's spelling. NOT read into a constant +# here: daemon's own conftest sets it at module scope, yet something imports +# this handler first, so a value captured at import time resolves to the live +# tree anyway. A seam that has to win an import race is not a seam - so the +# read happens inside _current_json_dir(), at call time, every call. +_TEST_LOG_DIR_ENV = "AIPASS_TEST_LOG_DIR" + MAX_LOG_ENTRIES = 100 # Default FIFO limit for log_operation (overridable via config) +def _current_json_dir() -> Path: + """Resolve the JSON directory NOW - never at import. + + Precedence, each step pinned in test_json_log_dir_seam.py: + 1. An explicit monkeypatch of JSON_DIR wins outright, detected by + VALUE rather than by identity. @prax's recipe compares by identity; + that breaks here, and the failure is not theoretical - test_contracts + calls importlib.reload(json_handler) while its autouse monkeypatch is + active, so the patch is undone by writing the PRE-reload object back + onto the POST-reload module. JSON_DIR is then equal to the default and + not identical to it, every later call reads "explicitly patched", and + the redirect silently stops working for the rest of the session. The + cost of value comparison is one lost distinction: a test that patches + JSON_DIR to the real directory on purpose is indistinguishable from + one that never patched at all. Pinned by name below. + 2. AIPASS_TEST_LOG_DIR redirects, under /daemon/daemon_json/ so a + root shared with other branches never collides. + 3. Otherwise the real directory. + + An EMPTY or blank value is absence, not a redirect - Path("") / "x" is + relative and would scatter daemon state wherever the process happens to be + standing. + """ + patched = Path(JSON_DIR) # coerced: some callers patch it with a str + if patched != _IMPORT_TIME_JSON_DIR: + return patched + + test_root = os.environ.get(_TEST_LOG_DIR_ENV) + if test_root and test_root.strip(): + return Path(test_root.strip()) / "daemon" / "daemon_json" + + return _IMPORT_TIME_JSON_DIR + + # os.replace on Windows raises PermissionError while ANY reader holds the # target open (no FILE_SHARE_DELETE on Python's open). Readers hold handles # for microseconds, so a short bounded retry converges; after the bound the @@ -113,17 +164,37 @@ def _get_caller_module_name() -> str: """ Auto-detect calling module name from call stack. + Walks past internal frames ([0] = this function, [1] = public function, + [2] = actual caller) and returns the stem of the caller's filename. + + Reads the frame directly rather than through inspect.stack(). MEASURED + 2026-08-31 in the hostile world that emulates a Windows box with no working + directory: inspect.stack() builds a FrameInfo per frame, and for any frame + whose filename is a PSEUDO-file - , which every interpreter -c + invocation and every exec'd hook puts on the stack - it reaches getmodule(), + whose os.path.realpath sits outside that function's every try. The whole + call then raises FileNotFoundError, so log_operation - the audit line daemon + writes on essentially every scheduler tick - took the caller down from inside + its own logging. On POSIX the equivalent raise happens earlier, where inspect + catches it, which is why this stood on Linux for as long as it existed. + + FrameInfo.filename is getsourcefile(frame) or getfile(frame), and both fall + back to co_filename for the frames this walk looks at, so the stem is the + same string by a route that touches no filesystem at all. + Returns: Module name (e.g., "imports_standard" from imports_standard.py) """ - stack = inspect.stack() - if len(stack) > 2: - caller_frame = stack[2] - caller_path = Path(caller_frame.filename) - module_name = caller_path.stem + # Skip frames: [0]=this function, [1]=public wrapper, [2]=actual caller + try: + caller_frame = sys._getframe(2) + except ValueError: + # Fewer than three frames - the old form's `len(stack) > 2` guard. + return "unknown" - if module_name and not module_name.startswith("_"): - return module_name + module_name = Path(caller_frame.f_code.co_filename).stem + if module_name and not module_name.startswith("_"): + return module_name return "unknown" @@ -173,12 +244,12 @@ def validate_json_structure(data: Any, json_type: str) -> bool: def get_json_path(module_name: str, json_type: str) -> Path: """Get path for module JSON file.""" filename = f"{module_name}_{json_type}.json" - return JSON_DIR / filename + return _current_json_dir() / filename def ensure_json_exists(module_name: str, json_type: str) -> bool: """Ensure JSON file exists, create from template if missing.""" - JSON_DIR.mkdir(parents=True, exist_ok=True) + _current_json_dir().mkdir(parents=True, exist_ok=True) json_path = get_json_path(module_name, json_type) @@ -221,6 +292,14 @@ def save_json(module_name: str, json_type: str, data: Any) -> bool: if json_type == "data" and isinstance(data, dict): data["last_updated"] = datetime.now().date().isoformat() + # save_json was the one writer that never created its own directory - it + # only ever worked because the live daemon_json/ is committed and therefore + # always present. The moment the directory is resolved (a redirected test + # root, a fresh checkout), _atomic_write_json's tempfile raises + # FileNotFoundError before a single byte is written. Found by the redirect, + # not by a review. + json_path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write_json(json_path, data) return True @@ -320,7 +399,7 @@ def update_data_metrics(module_name: str, **metrics: Any) -> bool: update_data_metrics("daemon", test_metric="working") console.print() - console.print(f"[green]Check {JSON_DIR}/ for created files:[/green]") + console.print(f"[green]Check {_current_json_dir()}/ for created files:[/green]") console.print(" [dim]-[/dim] daemon_config.json") console.print(" [dim]-[/dim] daemon_data.json") console.print(" [dim]-[/dim] daemon_log.json") diff --git a/src/aipass/daemon/apps/handlers/module_root.py b/src/aipass/daemon/apps/handlers/module_root.py new file mode 100644 index 000000000..6da0ae648 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/module_root.py @@ -0,0 +1,125 @@ +# =================== AIPass ==================== +# Name: module_root.py +# Description: Resolve a module's __file__ without an import-time cwd read +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""One guarded spelling for module-level __file__ resolution. + +ntpath.realpath reads the working directory UNCONDITIONALLY - not only for +relative paths, the way posixpath does - and Path.resolve() routes through it +(measured on the Windows CI gate 2026-08-31, @memory's finding). So on Windows +every module-level Path(__file__).resolve() is an import-time working-directory +dependency: a process whose cwd was deleted cannot import the module. + +MEASURED IN DAEMON, not assumed: with the handlers guard cured, 25 of 35 +importable daemon modules still died under the dead-cwd world, and every one of +them died on the SAME line - json_handler.py's _DAEMON_ROOT. Ten module-level +resolve sites exist in the tree; only the import-reached ones can kill an +import, and they route through here now instead of each growing its own +try/except. + +Shape mirrors @devpulse's module_root.module_file - the minimal ratified form - +because daemon has no module-level repo-root walk ending in a working-directory +fallback. Every daemon root constant is parents[N] off __file__, so the resolve +guard is the whole cure here; there is nothing for @trigger's SOURCE_ROOT +treatment to fix. +""" + +from pathlib import Path + +from aipass.prax import logger + +MODULE_NAME = "module_root" + +# Diagnostics this module could not emit, newest last. A swallow that RETAINS +# the error is not a silent catch: the failure stays readable from the process +# that took it, and tests assert on it. The list is the only sanctioned landing +# place because the obvious one - log it - is exactly what failed. Bounded, so a +# pathological caller cannot grow it without limit. +_UNREPORTED: list = [] +_UNREPORTED_CAP = 20 + + +def _retain(what: str, exc: BaseException) -> None: + """Keep a diagnostic failure that could not be reported anywhere else. + + Cannot raise, by construction: a list append and a slice. Every caller is a + handler running in a world where the filesystem, the logger, or both have + already failed, so anything that could itself fail belongs somewhere else. + + Args: + what: Which lane failed ("logger" or "audit"). + exc: The exception that lane raised. + """ + _UNREPORTED.append(f"{what}: {type(exc).__name__}: {exc}") + del _UNREPORTED[:-_UNREPORTED_CAP] + + +def module_file(file: str) -> Path: + """A module's own path, absolute, resolved when the filesystem allows. + + .resolve() is still attempted, because normalising symlinks is why the call + is there and it succeeds on every healthy machine. The fallback is only + reached in the world where the alternative is a dead import, and it is + sound: __file__ has been absolute since Python 3.9, so the return is the + right file either way - just spelled through the symlink rather than past + it. + + Args: + file: A module's __file__. + + Returns: + The module's path, resolved if the filesystem could be asked. + """ + path = Path(file) + try: + return path.resolve() + except OSError as exc: + _record_unresolved(path, exc) + return path + + +def _record_unresolved(path: Path, exc: OSError) -> None: + """Record the fallback without ever raising. + + EVERYTHING diagnostic lives inside this function's protection, the logger + call included. That placement is a fix, not a style choice: the first cut + logged from module_file() itself, one line above this call and OUTSIDE any + try, and a pin with a raising logger caught it. The world that reaches this + code is a machine whose filesystem cannot answer a basic question, and + @prax's logger construction reads the working directory - so "the logger is + also down" is the SAME world, not a contrived one. A guard that dies in its + own diagnostic is worse than no guard: it converts a survivable import into + a crash while claiming to prevent exactly that. + + debug, not warning: in the world that reaches this line every module import + takes it, and eight identical warnings describe one condition - reported + once, loudly, by whichever lane fails on it. + + Args: + path: The __file__ that could not be resolved. + exc: The OSError resolve() raised. + """ + try: + logger.debug( + "[%s] Cannot resolve %s (%s) - using its absolute spelling", + MODULE_NAME, + path, + type(exc).__name__, + ) + except Exception as inner: # noqa: BLE001 - a log line must never take an import down + _retain("logger", inner) + + try: + from aipass.daemon.apps.handlers.json import json_handler + + json_handler.log_operation( + "module_file_unresolved", + {"path": str(path), "error": f"{type(exc).__name__}: {exc}"}, + module_name=MODULE_NAME, + ) + except Exception as inner: # noqa: BLE001 - an audit line must never take an import down + _retain("audit", inner) diff --git a/src/aipass/daemon/apps/handlers/monitoring/activity_collector.py b/src/aipass/daemon/apps/handlers/monitoring/activity_collector.py index d7d058032..63cdfbf6e 100644 --- a/src/aipass/daemon/apps/handlers/monitoring/activity_collector.py +++ b/src/aipass/daemon/apps/handlers/monitoring/activity_collector.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: activity_collector.py # Description: Branch Activity Data Collector -# Version: 0.1.0 +# Version: 0.2.0 # Created: 2026-01-30 -# Modified: 2026-01-30 +# Modified: 2026-08-31 # ============================================= """ @@ -22,10 +22,11 @@ from aipass.prax import logger from aipass.daemon.apps.handlers.json import json_handler +from aipass.daemon.apps.handlers.module_root import module_file # Constants — find registry: env var > repo root > ~/.aipass/ -_REPO_ROOT = Path(__file__).resolve().parents[6] # src/aipass/daemon/apps/handlers/monitoring -> repo root +_REPO_ROOT = module_file(__file__).parents[6] # src/aipass/daemon/apps/handlers/monitoring -> repo root _REGISTRY_CANDIDATES = [ Path(os.environ.get("AIPASS_REGISTRY", "")), _REPO_ROOT / "AIPASS_REGISTRY.json", diff --git a/src/aipass/daemon/apps/handlers/schedule/discovery.py b/src/aipass/daemon/apps/handlers/schedule/discovery.py index 9afea51d4..c12cf0c8a 100644 --- a/src/aipass/daemon/apps/handlers/schedule/discovery.py +++ b/src/aipass/daemon/apps/handlers/schedule/discovery.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: discovery.py # Description: Decentralized .daemon/ schedule file discovery -# Version: 2.0.0 +# Version: 2.1.0 # Created: 2026-06-15 -# Modified: 2026-08-28 +# Modified: 2026-08-31 # ============================================= """ @@ -19,7 +19,7 @@ -------------------------------------------------- This module decides who the scheduler fires for, who the steward rotation walks, and whose inbox the sweep looks in. It now answers that question the -same way @memory's registry_scope and @ai_mail's registry/read.py answer it, +same way @memory's fleet gateway and @ai_mail's registry/read.py answer it, because a fleet definition with three implementations agrees only by coincidence. @@ -77,7 +77,13 @@ from aipass.prax import logger from aipass.daemon.apps.handlers.json import json_handler -_REPO_ROOT = Path(__file__).resolve().parents[6] # up to repo root +# @memory's PUBLIC gateway to the fleet definition (apps/modules/fleet.py, +# built on our dispatch 2a70bbcd): handlers/ is private implementation, and +# importing it cross-branch failed encapsulation + handlers on the checklist. +from aipass.memory.apps.modules import fleet +from aipass.daemon.apps.handlers.module_root import module_file + +_REPO_ROOT = module_file(__file__).parents[6] # up to repo root _SRC_AIPASS = _REPO_ROOT / "src" / "aipass" _REGISTRY_FILE = _REPO_ROOT / "AIPASS_REGISTRY.json" _PROJECTS_DIR_NAME = "projects" @@ -90,255 +96,161 @@ PASSPORT_RELATIVE = Path(".trinity") / "passport.json" -# The two values passport 2.0 defines for citizenship.residency. -RESIDENCY_CORE = "core" -RESIDENCY_RESIDENT = "resident" +# The values passport 2.0 defines for citizenship.residency. Re-exported from +# @memory rather than re-spelled: two copies of a vocabulary drift silently. +RESIDENCY_CORE = fleet.RESIDENCY_CORE +RESIDENCY_RESIDENT = fleet.RESIDENCY_RESIDENT REQUIRED_JOB_KEYS = {"id", "schedule", "prompt"} VALID_SCHEDULE_TYPES = {"daily", "hourly", "interval", "once", "rotation"} # Source labels on a citizen record — which registry vouched for it. SOURCE_AIPASS = "aipass" +SOURCE_EXTERNAL = "external" + +# The core registry's file name, as @memory's records spell it. +_CORE_REGISTRY_NAME = "AIPASS_REGISTRY.json" # Passport citizen_class that reads its mail live and must never be woken into # an interactive session by a scheduled job. MANAGER_CLASS = "manager" -def _load_registry() -> dict: - """Load AIPASS_REGISTRY.json. Returns empty dict on failure.""" - if not _REGISTRY_FILE.exists(): - logger.warning("[discovery] AIPASS_REGISTRY.json not found at %s", _REGISTRY_FILE) - return {} - try: - with open(_REGISTRY_FILE, "r", encoding="utf-8") as f: - return json.load(f) - except (json.JSONDecodeError, OSError) as e: - logger.error("[discovery] Failed to load registry: %s", e) - return {} - - def declared_residency(branch_path: Path) -> Optional[str]: """What the passport at *branch_path* declares itself to be. - The single reader for ``citizenship.residency`` on this branch. Every lane - that needs to know whether a branch is core or resident goes through here, - so the field is spelled once. - - An absent or unreadable passport declares NOTHING and does not raise. The - caller decides what silence means: for a core citizen it means "log it and - keep them", for a resident candidate it means "refuse". Returning None for - both is what lets one reader serve two policies. - - Args: - branch_path: Branch directory holding ``.trinity/passport.json``. - - Returns: - The declared residency string, or None when nothing is declared. + Delegates through @memory's ``modules/fleet`` gateway, which owns the field. Kept as a name + here because callers and tests in this branch import it from discovery, + and because re-spelling the read is exactly the duplication 2.1.0 removes. """ - passport = Path(branch_path) / PASSPORT_RELATIVE - try: - data = json.loads(passport.read_text(encoding="utf-8")) - except FileNotFoundError: - logger.debug("[discovery] No passport at %s — declares nothing", passport) - return None - except (OSError, ValueError, UnicodeDecodeError) as e: - logger.error("[discovery] Unreadable passport %s: %s", passport, e) - return None - - if not isinstance(data, dict): - logger.error("[discovery] Non-dict passport at %s — declares nothing", passport) - return None + return fleet.declared_residency(branch_path) - residency = data.get("citizenship", {}).get("residency") - return residency if isinstance(residency, str) else None +def _source_label(record: dict, repo_root: Path) -> str: + """A human label for WHICH registry vouched for this citizen. -def _refuse_resident(name: str, path: Path, registry_path: Path, reason: str) -> None: - """Log a refused resident candidate by name, path and reason. + Presentation only — nothing routes on it. It is printed by + ``drone @daemon rotation`` and carried in the rotation's JSON, so the + strings are a display contract rather than a policy one. - Every rejection funnels through here so none can be silent. A candidate - refused without a line in the log looks exactly like one that was never - discovered, and a parked project and a broken glob need different fixes. + @memory's record carries the registry FILE NAME, which is enough to tell + core from everything else but not enough to name the project. The project + directory is recovered from the citizen's own path, one level under + ``projects/``. A citizen outside both trees is labelled by its registry + stem, so the federated externals land as ``external/WREN`` rather than + silently reading as core. """ - logger.error( - "[discovery] REFUSED resident '%s' at %s (listed active in %s): %s", - name, - path, - registry_path.name, - reason, - ) + if record.get("registry") == _CORE_REGISTRY_NAME: + return SOURCE_AIPASS + path = Path(record["path"]) + projects_dir = repo_root / _PROJECTS_DIR_NAME -def _classify_resident(name: str, path: Path, registry_path: Path) -> bool: - """Decide one resident candidate on its own passport, naming any refusal. + # Asked rather than caught: a citizen outside projects/ is the ORDINARY + # federated-external case, not an exception, and catching ValueError here + # would read as error handling for something that is simply a third tier. + if not path.is_relative_to(projects_dir): + stem = str(record.get("registry", "")).replace("_REGISTRY.json", "") + return f"{SOURCE_EXTERNAL}/{stem}" if stem else SOURCE_EXTERNAL + + return f"{_PROJECTS_DIR_NAME}/{path.relative_to(projects_dir).parts[0]}" - Split out from the record loop so the decision can be read — and changed — - without the iteration that surrounds it. - """ - residency = declared_residency(path) - if residency == RESIDENCY_RESIDENT: - return True - if residency is None: - reason = "passport declares no residency (missing, unreadable, or no field)" - elif residency == RESIDENCY_CORE: - reason = f"passport declares '{RESIDENCY_CORE}' from inside {_PROJECTS_DIR_NAME}/" - else: - reason = f"passport declares unknown residency '{residency}'" - _refuse_resident(name, path, registry_path, reason) - return False +def active_citizens(repo_root: Optional[Path] = None) -> List[dict]: + """Return an ordered record per active citizen, in @memory's fleet order. + Record: {name, email, dir_name, path, source}. -def _citizen_records(registry: dict, base: Path, source: str, resident_registry: Optional[Path] = None) -> List[dict]: - """Build citizen records for one registry, resolving paths against `base`. + WHO COUNTS IS NOT DECIDED HERE ANY MORE (FPLAN-0460). The core-registry + read, the ``projects/*`` glob, the dot-filter and the two-key resident + rule all used to live in this module in a second copy. They are now one + call to :func:`fleet.fleet_branches`, because a fleet definition + with two implementations agrees only by coincidence — and the day the + federated-external anchor lands in the core registry, this lane gains + those citizens without a line changing here. That is the whole point of + consuming rather than mirroring. - `base` is the root a relative registry path is measured from: the repo root - for AIPASS_REGISTRY.json, the project root for a sealed project registry. - Project paths must NOT fall back to the repo root — 'src/baud/baud' happens - to exist in both trees, and resolving it repo-first picks the wrong one. + WHAT IS STILL DECIDED HERE is the one thing @memory deliberately left to + each caller: an address. Their ruling is that a branch with no ``email`` + is KEPT, because the path-based lanes (trinity push, rollover) still want + it. Daemon cannot use it — a job's owner IS an email and the wake targets + an email — so it is refused, and refused LOUDLY at error level. A citizen + dropped without a line in the log is indistinguishable from one that was + never discovered. Args: - registry: The parsed registry document. - base: Root that relative branch paths are resolved against. - source: Label recorded on each record — which registry vouched for it. - resident_registry: Set to the registry FILE when these rows are resident - candidates: each then also needs a passport declaring ``resident``, - and every refusal is named against this file. Left None for the - sealed core registry, whose citizens are included unconditionally. + repo_root: Root to resolve the fleet against; defaults to this + checkout's. Threaded through so a test can drive a temp tree + without patching module state. Returns: - One record per accepted branch: {name, email, dir_name, path, source}. + One record per addressable active citizen, fleet order preserved. """ + # Defaults to the module constant rather than passing None straight through, + # so patching _REPO_ROOT still redirects the whole lane — the seam every + # temp-tree test in this branch already drives. + root = Path(repo_root) if repo_root is not None else _REPO_ROOT + + # @memory deduplicates by resolved PATH — correct for their lanes, which are + # path-keyed. Daemon is email-keyed: a job's owner IS an address and the wake + # targets one, so two rows sharing an email are ambiguous here even when they + # name different directories. Deduplicated on daemon's own axis, and named. records = [] - for branch in registry.get("branches", []): - if branch.get("status", "") != "active": - continue - email = branch.get("email", "") - path_str = branch.get("path", "") - if not email or not path_str: - continue - path = Path(path_str) - if not path.is_absolute(): - path = base / path - name = branch.get("name", path.name) - if not path.exists(): - if resident_registry is not None: - _refuse_resident(name, path, resident_registry, "registry path does not exist on disk") + seen: dict = {} + for citizen in fleet.fleet_branches(root, name_from="registry"): + email = citizen.get("email") + if not email: + logger.error( + "[discovery] REFUSED '%s' at %s (listed active in %s): no email in the registry row — " + "daemon addresses jobs and wakes by email, so an addressless citizen cannot own one", + citizen.get("name", "?"), + citizen.get("path"), + citizen.get("registry", "?"), + ) continue - if resident_registry is not None and not _classify_resident(name, path, resident_registry): + + if email in seen: + logger.error( + "[discovery] REFUSED duplicate address %s at %s (listed active in %s): " + "already claimed by %s — daemon keys jobs and wakes by email, so a second " + "row with the same address would double-fire the first citizen's schedule", + email, + citizen.get("path"), + citizen.get("registry", "?"), + seen[email], + ) continue + + path = Path(citizen["path"]) + seen[email] = path records.append( { - "name": name, + "name": citizen.get("name", path.name), "email": email, "dir_name": path.name, "path": path, - "source": source, + "source": _source_label(citizen, root), } ) return records -def _project_registry_files() -> List[Path]: - """Candidate resident-project registries. DISCOVERY ONLY — decides nothing. - - Registry-led and shallow, and both halves are load-bearing. Exactly one - level under ``projects/``, so a registry nested deeper is out of reach; and - every dot-prefixed component refused by the explicit filter below, because - ``pathlib`` globs match hidden directories where a shell would not. On the - live tree the parked projects are excluded by BOTH, which is why each layer - is pinned alone — either one looks unnecessary until the other is removed. - - A checkout with no ``projects/`` returns empty and does not raise: CI runs - on exactly that tree, since ``projects/`` is gitignored. - - Returns: - Absolute registry paths, sorted, one per candidate project. - """ - projects_dir = _REPO_ROOT / _PROJECTS_DIR_NAME - if not projects_dir.is_dir(): - return [] - - files = [] - for path in sorted(projects_dir.glob(RESIDENT_REGISTRY_GLOB)): - if any(part.startswith(".") for part in path.relative_to(projects_dir).parts): - continue - files.append(path) - return files - - -def _load_registry_file(path: Path) -> dict: - """Load one registry JSON file. Returns empty dict on failure.""" - try: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - except (json.JSONDecodeError, OSError) as e: - logger.error("[discovery] Failed to load registry %s: %s", path, e) - return {} - - -def active_citizens() -> List[dict]: - """Return an ordered record per active citizen across both trees. - - Record: {name, email, dir_name, path, source}. Core citizens come first in - AIPASS_REGISTRY.json order, then residents in project-name order — this - ordering is the steward rotation's roster order. - - The two halves are judged by DIFFERENT rules on purpose. Core citizens come - from the sealed registry and are included unconditionally; one that declares - something other than 'core' is logged and KEPT, because an agent-writable - field must never be able to remove its own branch from the scheduler. - Resident candidates need both keys — see :func:`_classify_resident` — since - there the passport is the only thing separating a live project from a - parked one. - """ - citizens = _citizen_records(_load_registry(), _REPO_ROOT, SOURCE_AIPASS) - for citizen in citizens: - residency = declared_residency(citizen["path"]) - if residency != RESIDENCY_CORE: - logger.warning( - "[discovery] Core citizen %s declares residency %r, not '%s' — kept, " - "because the sealed registry is the anchor", - citizen["email"], - residency, - RESIDENCY_CORE, - ) - - for registry_file in _project_registry_files(): - project_root = registry_file.parent - source = f"{_PROJECTS_DIR_NAME}/{project_root.name}" - citizens.extend( - _citizen_records(_load_registry_file(registry_file), project_root, source, resident_registry=registry_file) - ) - - seen = set() - unique = [] - for citizen in citizens: - if citizen["email"] in seen: - logger.info("[discovery] Duplicate citizen %s ignored (%s)", citizen["email"], citizen["source"]) - continue - seen.add(citizen["email"]) - unique.append(citizen) - return unique - - -def active_branch_map() -> dict: +def active_branch_map(repo_root: Optional[Path] = None) -> dict: """Return dir_name -> branch_email for every active, registered citizen. Public entry point so sibling handlers (inbox_scanner) can resolve branch directories to owners without reaching into the private helpers. """ - return {c["dir_name"]: c["email"] for c in active_citizens()} + return {c["dir_name"]: c["email"] for c in active_citizens(repo_root)} -def branch_path_for(dir_name: str) -> Path: +def branch_path_for(dir_name: str, repo_root: Optional[Path] = None) -> Path: """Return the on-disk path for a branch directory name. Resolved from the citizen records so project citizens land in their own tree; falls back to src/aipass/ for anything unregistered. """ - for citizen in active_citizens(): + for citizen in active_citizens(repo_root): if citizen["dir_name"] == dir_name: return citizen["path"] return _SRC_AIPASS / dir_name diff --git a/src/aipass/daemon/apps/handlers/schedule/runstate.py b/src/aipass/daemon/apps/handlers/schedule/runstate.py index aba178297..b7153a238 100644 --- a/src/aipass/daemon/apps/handlers/schedule/runstate.py +++ b/src/aipass/daemon/apps/handlers/schedule/runstate.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: runstate.py # Description: Daemon runstate tracking and due-logic for decentralized scheduler -# Version: 1.1.0 +# Version: 1.4.0 # Created: 2026-06-15 -# Modified: 2026-08-12 +# Modified: 2026-08-31 # ============================================= """ @@ -17,13 +17,13 @@ import json from datetime import datetime, timedelta -from pathlib import Path from typing import Optional from aipass.prax import logger from aipass.daemon.apps.handlers.json import json_handler +from aipass.daemon.apps.handlers.module_root import module_file -_DAEMON_ROOT = Path(__file__).resolve().parents[3] # src/aipass/daemon/ +_DAEMON_ROOT = module_file(__file__).parents[3] # src/aipass/daemon/ RUNSTATE_FILE = _DAEMON_ROOT / "daemon_json" / "daemon_runstate.json" @@ -164,11 +164,105 @@ def _is_once_due(schedule: dict, completed: Optional[str], now: datetime) -> boo return False -def is_job_due(job: dict, runstate: dict) -> bool: +# A failed fire buys a short pause, not the rest of the period. The scheduler +# ticks about every two minutes and the windowed schedules allow +/-15, so +# without a bound a permanently-broken daily job would spawn ~8 agents a day. +# Ten minutes leaves room for a couple of honest retries inside one window. +_FAILURE_BACKOFF_MINUTES = 10 + +# A BLOCKED fire never started anything, so it buys an even shorter pause than a +# failure: the target is busy, which is a transient fact about the world and the +# retry is the point. The bound still has to exist. An interval job measures from +# its last ATTEMPT and a block writes no attempt, so with no hold at all a +# blocked-forever interval job would re-attempt on every ~2-minute tick for as +# long as the target stayed occupied. Five minutes still leaves ~5 attempts +# inside a windowed schedule's +/-15. +_BLOCKED_RETRY_MINUTES = 5 + +# Statuses record_job_failure() writes. Anything else - "success", "blocked", or +# a legacy entry with no status at all - is read as a completed run. +# +# "blocked" is deliberately NOT here. A block leaves last_run untouched, so +# _due_from() falls through to whatever the last real run wrote; listing it here +# would make the read say "never ran" for a job that ran fine yesterday. +_FAILURE_STATUSES = frozenset({"failed", "error", "timeout"}) + + +def _due_from(state: dict) -> Optional[str]: + """The timestamp a WINDOWED schedule should measure its period from. + + ``last_run`` means "when did this last ATTEMPT" - the failure path stamps + it too, and the queue display depends on that. Due-ness asks a different + question: has this period's work been DONE? So it measures from the last + SUCCESS, and a failed fire no longer consumes the day it failed in. + + Legacy entries predating ``last_success_at`` carry only ``last_run`` and a + status. Those are read as runs: absence of a failure marker is not evidence + of a failure, and treating them as never-succeeded would re-fire every + already-done job on the machine the moment this landed. + """ + success = state.get("last_success_at") + if success: + return success + if state.get("last_status") in _FAILURE_STATUSES: + return None + return state.get("last_run") + + +def _in_failure_backoff(state: dict, now: datetime) -> bool: + """True while a recent failure should hold off the next attempt.""" + failed_at = state.get("last_failure_at") + if not failed_at: + return False + # A later success clears the hold - the old last_failure_at stays in the + # record as history and must not keep braking a job that recovered. + success = state.get("last_success_at") + if success and success >= failed_at: + return False + try: + failed_dt = datetime.fromisoformat(failed_at) + except (ValueError, TypeError) as e: + logger.info("[runstate] Failure backoff parse failed for %r: %s", failed_at, e) + return False + return (now - failed_dt) < timedelta(minutes=_FAILURE_BACKOFF_MINUTES) + + +def _in_blocked_hold(state: dict, now: datetime) -> bool: + """True while a recent BLOCK should space out the next attempt. + + Separate from the failure backoff because it answers a different question. + A failure means the wake ran and went wrong; a block means it never started + at all - the target was busy, paused, or already held the lock. The job's + period is untouched either way, but the block is both cheaper to retry (no + agent spawns) and likelier to clear on its own, so it holds for less time. + """ + blocked_at = state.get("last_blocked_at") + if not blocked_at: + return False + # Anything that happened AFTER the block ends the hold: a success means the + # target freed up, and a failure hands the job to the longer failure + # backoff. The old last_blocked_at stays in the record as history. + for later in (state.get("last_success_at"), state.get("last_failure_at")): + if later and later >= blocked_at: + return False + try: + blocked_dt = datetime.fromisoformat(blocked_at) + except (ValueError, TypeError) as e: + logger.info("[runstate] Blocked hold parse failed for %r: %s", blocked_at, e) + return False + return (now - blocked_dt) < timedelta(minutes=_BLOCKED_RETRY_MINUTES) + + +def is_job_due(job: dict, runstate: dict, now: Optional[datetime] = None) -> bool: """ Check if a discovered job should fire now. Merges job schedule info with runstate tracking data. + + ``now`` is injectable so due-ness can be asserted against a stated instant. + Every helper below already took its clock as an argument; this function was + the one that reached for datetime.now() itself, which made the behaviour + around a window boundary untestable except by luck of the wall clock. """ if not job.get("enabled", True): return False @@ -176,16 +270,30 @@ def is_job_due(job: dict, runstate: dict) -> bool: state = get_job_state(runstate, job["owner"], job["id"]) last_run = state.get("last_run") completed = state.get("completed") - now = datetime.now() + if now is None: + now = datetime.now() + + if _in_failure_backoff(state, now): + return False + + if _in_blocked_hold(state, now): + return False + + # Windowed schedules measure from the last SUCCESS; interval measures from + # the last ATTEMPT. That split is deliberate, not an oversight: an interval + # job measured from its last success would be due on EVERY tick forever + # once it started failing, because the elapsed time only grows. Its + # interval is already the bound it needs. + since_success = _due_from(state) schedule = job.get("schedule", {}) sched_type = schedule.get("type", "") checkers = { - "daily": lambda: _is_daily_due(schedule, last_run, now), + "daily": lambda: _is_daily_due(schedule, since_success, now), # A rotation job is a daily job that picks a different target each night. - "rotation": lambda: _is_daily_due(schedule, last_run, now), - "hourly": lambda: _is_hourly_due(schedule, last_run, now), + "rotation": lambda: _is_daily_due(schedule, since_success, now), + "hourly": lambda: _is_hourly_due(schedule, since_success, now), "interval": lambda: _is_interval_due(schedule, last_run, now), "once": lambda: _is_once_due(schedule, completed, now), } @@ -202,8 +310,38 @@ def is_job_due(job: dict, runstate: dict) -> bool: def _calc_next_run(schedule: dict, last_run_ts: str) -> Optional[str]: - """Calculate the next run time given schedule and a last_run timestamp.""" - now = datetime.now() + """Calculate the next run time given schedule and a last_run timestamp. + + Every branch answers from LAST_RUN_TS, never from the wall clock. That is + the fix for a live defect, not a refactor: @vera's `daily @ 10:00` fired at + 09:45:11 - the legitimate leading edge of the +/-15min window - and the old + daily branch, reading now(), advertised next_run = 10:00 THE SAME DAY. But + _already_ran_today consumes the whole calendar day, so is_job_due answered + False right through 10:14 and True only the next morning. The field named a + wake that could not happen. + + The rule the windowed branches follow is the one is_job_due enforces: firing + consumes the PERIOD, not the instant. A daily fire consumes its calendar day + whenever in the window it landed, so the next one is the following day at + target; an hourly fire consumes its clock hour. Interval already did this + correctly - it was the only branch using the argument it was handed, which + is the whole tell. + + KNOWN AND DELIBERATE SLACK: this returns the TARGET time, while the + scheduler will actually fire up to 15 minutes earlier, at the leading edge + of the window. Returning target-15min was considered and rejected - "daily @ + 10:00" is what the owner configured and what the queue should echo, and the + alternative puts window arithmetic into a field whose readers are humans. + The pin in test_next_run_agrees_with_due.py allows exactly that much + earliness and no more, so under-reporting by a whole PERIOD stays red. + + Args: + schedule: The job's schedule block. + last_run_ts: ISO timestamp of the firing this next_run follows. + + Returns: + ISO timestamp of the next run, or None if the schedule cannot be parsed. + """ sched_type = schedule.get("type", "") if sched_type in ("daily", "rotation"): @@ -213,9 +351,13 @@ def _calc_next_run(schedule: dict, last_run_ts: str) -> Optional[str]: except (ValueError, AttributeError) as e: logger.info("[runstate] calc_next_run daily time parse failed: %s", e) return None - next_dt = now.replace(hour=target_h, minute=target_m, second=0, microsecond=0) - if next_dt <= now: - next_dt += timedelta(days=1) + try: + last_dt = datetime.fromisoformat(last_run_ts) + except (ValueError, TypeError) as e: + logger.info("[runstate] calc_next_run daily last_run parse failed: %s", e) + return None + # The day of last_run is spent, whenever in its window the fire landed. + next_dt = (last_dt + timedelta(days=1)).replace(hour=target_h, minute=target_m, second=0, microsecond=0) return next_dt.isoformat() if sched_type == "hourly": @@ -225,9 +367,13 @@ def _calc_next_run(schedule: dict, last_run_ts: str) -> Optional[str]: except (ValueError, TypeError) as e: logger.info("[runstate] calc_next_run hourly time parse failed: %s", e) return None - next_dt = now.replace(minute=target_m, second=0, microsecond=0) - if next_dt <= now: - next_dt += timedelta(hours=1) + try: + last_dt = datetime.fromisoformat(last_run_ts) + except (ValueError, TypeError) as e: + logger.info("[runstate] calc_next_run hourly last_run parse failed: %s", e) + return None + # The clock hour of last_run is spent, same rule one unit down. + next_dt = (last_dt + timedelta(hours=1)).replace(minute=target_m, second=0, microsecond=0) return next_dt.isoformat() if sched_type == "interval": @@ -236,8 +382,11 @@ def _calc_next_run(schedule: dict, last_run_ts: str) -> Optional[str]: last_dt = datetime.fromisoformat(last_run_ts) return (last_dt + timedelta(minutes=interval)).isoformat() except (ValueError, TypeError) as e: + # None, like every other parse failure here. The old form returned + # now(), which advertises "due immediately" for a job whose schedule + # could not be read - the loudest possible wrong answer. logger.info("[runstate] calc_next_run interval parse failed: %s", e) - return now.isoformat() + return None if sched_type == "once": return schedule.get("due_date") @@ -292,6 +441,38 @@ def record_job_failure( json_handler.log_operation("record_job_failure", {"key": key, "status": status}) +def record_job_blocked( + runstate: dict, + owner: str, + job_id: str, + reason: str, + timestamp: Optional[str] = None, +) -> None: + """Record a fire that never STARTED - the target was busy, not broken. + + BLOCKED IS NOT RAN. Unlike both other writers this one leaves ``last_run`` + alone, because ``last_run`` means "when did this last attempt actually + happen" and a refused wake is not an attempt that happened. Stamping it was + the reported defect: a leftover interactive session in a branch made the + scheduler record a run it never made, and the next day's fire was suppressed + by a room nobody was sitting in. + + ``last_failure_at`` is left alone too - a block is not a failure and must + not arm the failure backoff. ``last_blocked_at`` carries its own, shorter + hold (see ``_in_blocked_hold``). + """ + if timestamp is None: + timestamp = datetime.now().isoformat() + + key = job_key(owner, job_id) + entry = runstate.setdefault("jobs", {}).setdefault(key, {}) + entry["last_status"] = "blocked" + entry["last_blocked_at"] = timestamp + entry["last_error"] = reason[:500] + + json_handler.log_operation("record_job_blocked", {"key": key}) + + def prune_orphans(runstate: dict, active_keys: set) -> int: """Remove runstate entries for jobs that no longer exist. Returns count pruned.""" jobs = runstate.get("jobs", {}) diff --git a/src/aipass/daemon/apps/handlers/update/data_loader.py b/src/aipass/daemon/apps/handlers/update/data_loader.py index ee1b90af3..654af0f7e 100644 --- a/src/aipass/daemon/apps/handlers/update/data_loader.py +++ b/src/aipass/daemon/apps/handlers/update/data_loader.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: data_loader.py # Description: DAEMON Data Loading Handler -# Version: 1.0.0 +# Version: 1.1.0 # Created: 2026-01-29 -# Modified: 2026-01-29 +# Modified: 2026-08-31 # ============================================= """ @@ -11,17 +11,17 @@ """ import json -from pathlib import Path from typing import Dict, Any, List from aipass.prax import logger from aipass.daemon.apps.handlers.json import json_handler +from aipass.daemon.apps.handlers.module_root import module_file # ============================================= # CONSTANTS # ============================================= -_DAEMON_ROOT = Path(__file__).resolve().parents[3] # src/aipass/daemon/ +_DAEMON_ROOT = module_file(__file__).parents[3] # src/aipass/daemon/ INBOX_PATH = _DAEMON_ROOT / "ai_mail.local" / "inbox.json" LOCAL_PATH = _DAEMON_ROOT / "DAEMON.local.json" diff --git a/src/aipass/daemon/apps/modules/activity_report.py b/src/aipass/daemon/apps/modules/activity_report.py index 826aca247..6985b3f26 100644 --- a/src/aipass/daemon/apps/modules/activity_report.py +++ b/src/aipass/daemon/apps/modules/activity_report.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: activity_report.py # Description: Branch Activity Report Generator Module -# Version: 0.3.0 +# Version: 0.4.0 # Created: 2026-01-30 -# Modified: 2026-08-16 +# Modified: 2026-08-31 # ============================================= """ @@ -206,7 +206,10 @@ def _render_entry_health(branch_name: str) -> str: try: from aipass.memory.apps.modules.health import get_branch_health - except ImportError as e: + # OSError as well: a cross-branch optional import can fail on the FILESYSTEM, + # not only on absence. @memory imports clean under a dead cwd today (measured + # 2026-08-31); this catches the condition rather than today's measurement. + except (ImportError, OSError) as e: logger.warning("[DAEMON] activity_report: @memory health API unavailable: %s", e) lines.append(f" {SYMBOL_WARNING} UNAVAILABLE - @memory health API not importable: {e}") return "\n".join(lines) diff --git a/src/aipass/daemon/apps/modules/inbox_sweep.py b/src/aipass/daemon/apps/modules/inbox_sweep.py index 592d431fb..e92f64605 100644 --- a/src/aipass/daemon/apps/modules/inbox_sweep.py +++ b/src/aipass/daemon/apps/modules/inbox_sweep.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: inbox_sweep.py # Description: Fleet inbox sweep — wake branches sitting on mail unread past 24h -# Version: 1.0.0 +# Version: 1.1.0 # Created: 2026-08-11 -# Modified: 2026-08-11 +# Modified: 2026-08-31 # ============================================= """ @@ -101,7 +101,10 @@ def _apply_wake_policy(entries: List[dict]) -> None: """ try: from aipass.ai_mail.apps.handlers.dispatch.wake import is_wake_blocked - except ImportError as e: + # OSError, not only ImportError: MEASURED 2026-08-31 - this import raises + # FileNotFoundError under a dead working directory, and the sweep must degrade + # rather than crash on an optional consult. + except (ImportError, OSError) as e: logger.warning("[inbox_sweep] Wake blocklist unavailable, skipping policy check: %s", e) return diff --git a/src/aipass/daemon/apps/modules/rotation.py b/src/aipass/daemon/apps/modules/rotation.py index 56c5b478c..23a6b423e 100644 --- a/src/aipass/daemon/apps/modules/rotation.py +++ b/src/aipass/daemon/apps/modules/rotation.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: rotation.py # Description: Steward rotation firing + status surface (drone @daemon rotation) -# Version: 1.0.0 +# Version: 1.1.0 # Created: 2026-08-12 -# Modified: 2026-08-12 +# Modified: 2026-08-31 # ============================================= """ @@ -108,7 +108,13 @@ def _apply_wake_blocklist(roster: List[dict]) -> List[dict]: """ try: from aipass.ai_mail.apps.handlers.dispatch.wake import is_wake_blocked - except ImportError as e: + # OSError, not only ImportError: MEASURED 2026-08-31 - importing ai_mail's wake + # module under a dead working directory raises FileNotFoundError today, and an + # uncaught one here takes the whole rotation down from inside an OPTIONAL + # consult. The fallback is unchanged and still fail-open by design (an + # unfiltered roster, logged); what changes is that the world reaching it no + # longer arrives as a traceback. + except (ImportError, OSError) as e: logger.warning("[rotation] Wake blocklist unavailable, roster unfiltered: %s", e) return roster diff --git a/src/aipass/daemon/apps/modules/run.py b/src/aipass/daemon/apps/modules/run.py index cdc772d13..00a43e498 100644 --- a/src/aipass/daemon/apps/modules/run.py +++ b/src/aipass/daemon/apps/modules/run.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: run.py # Description: Manual one-tick scheduler command (drone @daemon run) -# Version: 1.1.0 +# Version: 1.3.0 # Created: 2026-06-15 -# Modified: 2026-08-12 +# Modified: 2026-08-31 # ============================================= """ @@ -15,7 +15,6 @@ import sys import time -from pathlib import Path from typing import List from aipass.prax import logger @@ -29,9 +28,11 @@ is_job_due, update_job_runstate, record_job_failure, + record_job_blocked, job_key, prune_orphans, ) +from aipass.daemon.apps.handlers.module_root import module_file try: import fcntl @@ -39,11 +40,28 @@ fcntl = None # type: ignore[assignment] logger.info("[run] fcntl unavailable (Windows)") -_DAEMON_ROOT = Path(__file__).resolve().parents[2] # src/aipass/daemon/ +_DAEMON_ROOT = module_file(__file__).parents[2] # src/aipass/daemon/ LOCK_FILE = _DAEMON_ROOT / "daemon_json" / "schedule.lock" HANDLED_COMMANDS = {"run"} +# What a single fire attempt ended as. Three states, not two: a wake that was +# REFUSED before anything started is neither a run nor a failure, and collapsing +# it into either one is the defect this vocabulary exists to prevent. +OUTCOME_FIRED = "fired" +OUTCOME_FAILED = "failed" +OUTCOME_BLOCKED = "blocked" + +# wake_branch gates that refuse BEFORE a process exists. Read by step LABEL from +# the DispatchStatus rather than by matching the prose in `summary`, which is a +# human-facing string ai_mail is free to reword. +# +# Deliberately NOT here: "resolve" (the branch does not exist) and "blocklist" +# (the target is refused by policy). Both are decided, not transient - nothing +# about the next two minutes changes the answer, so retrying them on every tick +# inside the window is noise. Those stay failures and keep the failure backoff. +_BLOCKED_STEPS = ("pause", "lock", "blocked", "lock-acquire") + def print_introspection(): """Display module introspection info.""" @@ -122,6 +140,20 @@ def _should_notify(job: dict) -> bool: return job.get("notify", True) +def _blocked_reason(status) -> str: + """Name the gate that refused to START this wake, or "" if none did. + + Reads the gate's own verdict via find_step() — a step counts only when it + actually FAILED, because the same labels are written on the happy path too + ("lock: No active lock — agent is sleeping" is an `ok`, not a refusal). + """ + for label in _BLOCKED_STEPS: + step = status.find_step(label) + if step is not None and step[0] == "fail": + return f"{label}: {step[2]}" + return "" + + def _fire_job(job: dict, runstate: dict) -> tuple: """Fire a single job via direct wake_branch import (DPLAN-0204 path A). @@ -129,10 +161,18 @@ def _fire_job(job: dict, runstate: dict) -> tuple: are handed to the rotation module, which owns target selection and pointer state (DPLAN-0287). - Returns (ok: bool, error_msg: str). + Returns (outcome: str, detail: str) where outcome is one of OUTCOME_FIRED, + OUTCOME_FAILED or OUTCOME_BLOCKED. The third state is the point: only a wake + that actually STARTED consumes the job's period. + + Rotation keeps the two-state answer and is mapped, not reclassified. A busy + steward is already a recorded MISS there — the pointer advanced and a + different citizen gets the night — so that night is genuinely spent, and + calling it blocked would re-fire a rotation whose turn was already taken. """ if job.get("schedule", {}).get("type") == ROTATION_TYPE: - return fire_rotation(job, runstate) + ok, detail = fire_rotation(job, runstate) + return (OUTCOME_FIRED if ok else OUTCOME_FAILED), detail # Cross-branch handler import authorized by DPLAN-0204 §2.8 from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch # noqa: E402 @@ -163,26 +203,44 @@ def _fire_job(job: dict, runstate: dict) -> tuple: auto=True, sender="@daemon", model=model, + # Every wake this module makes was fired by a clock, so it is a + # scheduled wake by definition — the flag describes THIS caller's + # lane, never the target. A manager target then goes headless + # through dispatch_monitor (self-terminating, context pin, bounce + # mail, lock cleanup) instead of an interactive tmux room that + # nothing ever closes, and which blocked the next night's fire. + # Deciding it per-target would mean reading the target's passport + # here — a second copy of the manager gate wake_branch owns. + scheduled=True, ) if ok: _log(f"OK: {owner}/{job_id} — {status.summary}") logger.info("[run] Fired %s/%s successfully", owner, job_id) if notify: notify_complete(owner, job_id, status.summary) - return True, "" - else: - msg = status.summary - _log(f"FAIL: {owner}/{job_id} — {msg}") - logger.warning("[run] Failed to fire %s/%s: %s", owner, job_id, msg) - if notify: - notify_error(owner, job_id, msg) - return False, msg + return OUTCOME_FIRED, "" + + blocked = _blocked_reason(status) + if blocked: + # No telegram: a deferral is not an error, and a target that stays + # busy would otherwise ping once per retry for as long as it sat + # there. The console line and the prax record still name it. + _log(f"BLOCKED: {owner}/{job_id} — {blocked}; stays due, retries this window") + logger.info("[run] Blocked firing %s/%s: %s — not recorded as a run", owner, job_id, blocked) + return OUTCOME_BLOCKED, blocked + + msg = status.summary + _log(f"FAIL: {owner}/{job_id} — {msg}") + logger.warning("[run] Failed to fire %s/%s: %s", owner, job_id, msg) + if notify: + notify_error(owner, job_id, msg) + return OUTCOME_FAILED, msg except Exception as e: logger.error("[run] Exception firing %s/%s: %s", owner, job_id, e) _log(f"ERROR: {owner}/{job_id} — {e}") if notify: notify_error(owner, job_id, str(e)) - return False, str(e) + return OUTCOME_FAILED, str(e) def run_tick(dry_run: bool = False) -> dict: @@ -197,6 +255,7 @@ def run_tick(dry_run: bool = False) -> dict: "due": 0, "fired": 0, "failed": 0, + "blocked": 0, "skipped": 0, } @@ -253,19 +312,27 @@ def run_tick(dry_run: bool = False) -> dict: # Step 4: Fire due jobs for job in due_jobs: - ok, error_msg = _fire_job(job, runstate) - if ok: + outcome, detail = _fire_job(job, runstate) + if outcome == OUTCOME_FIRED: results["fired"] += 1 update_job_runstate(runstate, job["owner"], job["id"], job["schedule"]) + elif outcome == OUTCOME_BLOCKED: + # Never stamps last_run — the job stays due and the next tick tries + # again inside the same window. + results["blocked"] += 1 + record_job_blocked(runstate, job["owner"], job["id"], detail) else: results["failed"] += 1 - record_job_failure(runstate, job["owner"], job["id"], error_msg) + record_job_failure(runstate, job["owner"], job["id"], detail) save_runstate(runstate) if job != due_jobs[-1]: time.sleep(1.0) - _log(f"Tick complete: {results['fired']} fired, {results['failed']} failed, {results['skipped']} skipped") + _log( + f"Tick complete: {results['fired']} fired, {results['failed']} failed, " + f"{results['blocked']} blocked, {results['skipped']} skipped" + ) return results diff --git a/src/aipass/daemon/apps/modules/timer_install.py b/src/aipass/daemon/apps/modules/timer_install.py index bc8d43781..afbfe0de2 100644 --- a/src/aipass/daemon/apps/modules/timer_install.py +++ b/src/aipass/daemon/apps/modules/timer_install.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: timer_install.py # Description: Idempotent systemd user timer installer for daemon scheduler -# Version: 1.0.0 +# Version: 1.1.0 # Created: 2026-06-25 -# Modified: 2026-06-25 +# Modified: 2026-08-31 # ============================================= """ @@ -23,9 +23,17 @@ from aipass.prax import logger from aipass.cli.apps.modules import console, error from aipass.daemon.apps.handlers.json import json_handler +from aipass.daemon.apps.handlers.module_root import module_file -_DAEMON_ROOT = Path(__file__).resolve().parents[2] +_DAEMON_ROOT = module_file(__file__).parents[2] _UNIT_DIR = Path.home() / ".config" / "systemd" / "user" + +# The shared AIPass state directory. A MODULE CONSTANT rather than a +# Path.home() call inside _install(), because a path computed at call time +# has no seam: the test could patch _DAEMON_ROOT and _UNIT_DIR and still +# watch the suite mkdir the real ~/.aipass, which holds admin_grant.key and +# commons.db. Found by the audit-tests lane, red-first before this line moved. +_STATE_DIR = Path.home() / ".aipass" _SERVICE_NAME = "daemon-tick.service" _TIMER_NAME = "daemon-tick.timer" @@ -124,7 +132,7 @@ def _install() -> int: console.print("[dim]Verify: systemctl --user list-timers | grep daemon[/dim]") console.print() - Path.home().joinpath(".aipass").mkdir(parents=True, exist_ok=True) + _STATE_DIR.mkdir(parents=True, exist_ok=True) logger.info("[timer_install] daemon-tick timer installed and started") return 0 diff --git a/src/aipass/daemon/apps/modules/wakeup_ops.py b/src/aipass/daemon/apps/modules/wakeup_ops.py index 28a8ef27c..57714a919 100644 --- a/src/aipass/daemon/apps/modules/wakeup_ops.py +++ b/src/aipass/daemon/apps/modules/wakeup_ops.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: wakeup_ops.py # Description: Wake-Up Cron Operations Module -# Version: 2.0.0 +# Version: 2.1.0 # Created: 2026-03-08 -# Modified: 2026-03-10 +# Modified: 2026-08-31 # ============================================= """ @@ -19,7 +19,10 @@ try: from aipass.cli.apps.modules.display import console -except ImportError: +# OSError as well: an optional cross-branch import can fail on the filesystem, +# not only on absence. @cli imports clean under a dead cwd today (measured +# 2026-08-31); the guard catches the condition rather than today's measurement. +except (ImportError, OSError): from rich.console import Console console = Console() diff --git a/src/aipass/daemon/tests/test_discovery.py b/src/aipass/daemon/tests/test_discovery.py index 59ed03771..17fefd493 100644 --- a/src/aipass/daemon/tests/test_discovery.py +++ b/src/aipass/daemon/tests/test_discovery.py @@ -20,8 +20,7 @@ declared_residency, _validate_job, _load_schedule_file, - _citizen_records, - _project_registry_files, + _PROJECTS_DIR_NAME, REQUIRED_JOB_KEYS, RESIDENCY_CORE, RESIDENCY_RESIDENT, @@ -146,38 +145,60 @@ def test_non_list_jobs(self, tmp_path): # ── _build_branch_map ──────────────────────────────── -class TestCitizenRecords: - def _emails(self, registry, root): - with patch(f"{DISCOVERY}._REPO_ROOT", root): - return [c["email"] for c in _citizen_records(registry, root, "aipass")] +class TestCitizenRecordShape: + """What daemon builds from @memory's rows. - def test_active_branches_only(self, sample_registry, temp_src_aipass): + The registry READ that used to live here is gone (FPLAN-0460) — it is + registry_scope's now, and pinning a copy of it in this file is what let the + two definitions drift in the first place. What remains daemon's own is the + mapping into the {name, email, dir_name, path, source} record every lane in + this branch consumes, and the address refusal @memory deliberately left to + each caller. + """ + + def test_record_carries_dir_name_and_source(self, sample_registry, temp_src_aipass): root, src = temp_src_aipass + (root / "AIPASS_REGISTRY.json").write_text(json.dumps(sample_registry)) (src / "testbranch").mkdir() - (src / "inactive").mkdir() - emails = self._emails(sample_registry, root) - assert emails == ["@testbranch"] + write_passport(src / "testbranch", residency=RESIDENCY_CORE) - def test_empty_registry(self, temp_src_aipass): - root, _src = temp_src_aipass - assert _citizen_records({}, root, "aipass") == [] - assert _citizen_records({"branches": []}, root, "aipass") == [] + record = active_citizens(root)[0] - def test_missing_path_skipped(self, sample_registry, temp_src_aipass): - root, _src = temp_src_aipass - assert self._emails(sample_registry, root) == [] + assert record["email"] == "@testbranch" + assert record["dir_name"] == "testbranch" + assert record["source"] == "aipass" + assert record["path"] == src / "testbranch" - def test_record_carries_dir_name_and_source(self, sample_registry, temp_src_aipass): + def test_name_keeps_the_registry_spelling_not_the_directory(self, sample_registry, temp_src_aipass): + """`name` is the registry's own field, casing and all. + + branch-health looks branches up by the registry spelling (uppercase), + so switching to the directory name would break it silently. Pinned + because @memory's default is the OTHER one — name_from='path' — and a + future reader will wonder why this call passes 'registry'. + """ root, src = temp_src_aipass + (root / "AIPASS_REGISTRY.json").write_text(json.dumps(sample_registry)) (src / "testbranch").mkdir() - with patch(f"{DISCOVERY}._REPO_ROOT", root): - record = _citizen_records(sample_registry, root, "aipass")[0] + write_passport(src / "testbranch", residency=RESIDENCY_CORE) + + record = active_citizens(root)[0] + + assert record["name"] == "TESTBRANCH" assert record["dir_name"] == "testbranch" - assert record["source"] == "aipass" - assert record["path"] == src / "testbranch" + def test_inactive_branches_never_arrive(self, sample_registry, temp_src_aipass): + root, src = temp_src_aipass + (root / "AIPASS_REGISTRY.json").write_text(json.dumps(sample_registry)) + (src / "testbranch").mkdir() + (src / "inactive").mkdir() -# ── discover_jobs (integration) ────────────────────── + assert [c["email"] for c in active_citizens(root)] == ["@testbranch"] + + def test_empty_registry_is_not_an_error(self, temp_src_aipass): + root, _src = temp_src_aipass + (root / "AIPASS_REGISTRY.json").write_text(json.dumps({"branches": []})) + assert active_citizens(root) == [] class TestDiscoverJobs: @@ -348,11 +369,16 @@ def projects_tree(temp_src_aipass, sample_registry): @contextmanager def patched_roots(root: Path, src: Path): - """Point discovery at a temp repo tree (both registries and both trees).""" + """Point discovery at a temp repo tree. + + _REGISTRY_FILE is gone with daemon's own reader — @memory resolves the core + registry from the root it is handed, so _REPO_ROOT is the only seam the + fleet lane needs now. _SRC_AIPASS stays: branch_path_for still falls back to + it for an unregistered directory. + """ with ( patch(f"{DISCOVERY}._REPO_ROOT", root), patch(f"{DISCOVERY}._SRC_AIPASS", src), - patch(f"{DISCOVERY}._REGISTRY_FILE", root / "AIPASS_REGISTRY.json"), ): yield @@ -407,6 +433,36 @@ def test_duplicate_email_keeps_the_first_registry(self, projects_tree): assert [c["email"] for c in citizens].count("@testbranch") == 1 assert citizens[0]["source"] == "aipass" + def test_the_duplicate_address_is_refused_by_name(self, projects_tree): + """Deduplication is daemon's own, on daemon's own axis, and never silent. + + @memory deduplicates by resolved PATH — right for their path-keyed + lanes. Two rows with the SAME email and DIFFERENT paths survive that + and both reach here, which for an email-keyed scheduler means one + citizen's schedule fires twice. Red-first: without the dedup in + active_citizens() this returns @testbranch twice. + """ + root, src, _citizen, _decoy = projects_tree + dupe_root = root / "projects" / "dupe" + (dupe_root / "src" / "testbranch").mkdir(parents=True) + write_passport(dupe_root / "src" / "testbranch", residency=RESIDENCY_RESIDENT) + (dupe_root / "DUPE_REGISTRY.json").write_text( + json.dumps( + { + "branches": [ + {"name": "TESTBRANCH", "email": "@testbranch", "path": "src/testbranch", "status": "active"} + ] + } + ) + ) + + with patched_roots(root, src), patch(f"{DISCOVERY}.logger") as log: + citizens = active_citizens() + + assert [c["email"] for c in citizens].count("@testbranch") == 1 + text = " ".join(str(c) for c in log.error.call_args_list) + assert "@testbranch" in text and "DUPE_REGISTRY.json" in text + class TestCitizenClass: def test_reads_class_from_passport(self, tmp_path): @@ -525,28 +581,57 @@ def test_non_string_value_declares_nothing(self, tmp_path): write_passport(tmp_path, raw=json.dumps({"citizenship": {"residency": 5}})) assert declared_residency(tmp_path) is None - def test_non_dict_passport_declares_nothing(self, tmp_path): - write_passport(tmp_path, raw="[]") - assert declared_residency(tmp_path) is None + @pytest.mark.parametrize( + "raw, label", + [ + ("[]", "list root"), + ('"core"', "string root"), + ("5", "number root"), + ("null", "null root"), + ("true", "bool root"), + ('{"citizenship": "core"}', "citizenship present but not a dict"), + ], + ) + def test_a_malformed_passport_declares_nothing_and_does_not_raise(self, tmp_path, raw, label): + """Every shape that used to crash the whole fleet lane now returns None. + + History, because the pin is worth more than the assertion: daemon's own + deleted copy guarded only the ROOT, registry_scope 2.1.0 guarded neither, + and 2.2.0 guards both after @memory found that the second `.get` in + `data.get("citizenship", {}).get("residency")` was unprotected — a shape + this branch never tested and could not have reported. + + declared_residency is called once per citizen by fleet_branches, so a + raise here is not one refused branch: it is every lane in the fleet, + killed by one bad file. Parametrized rather than one case, because a fix + verified against a list alone leaves the other five alive. + """ + write_passport(tmp_path, raw=raw) + assert declared_residency(tmp_path) is None, label class TestRegistryLedDiscovery: """Candidate discovery finds REGISTRIES, one level down, dots refused.""" def test_finds_one_level_registries(self, core_only): + """Both one-level projects reach the roster, in project-name order. + + Asserted on WHO ARRIVES rather than on which registry files were + globbed: the glob is registry_scope's now, and re-pinning its internals + here would rebuild the second implementation FPLAN-0460 removed. + """ root, src = core_only make_project(root, "alpha", branch="alpha", residency=RESIDENCY_RESIDENT) make_project(root, "beta", branch="beta", residency=RESIDENCY_RESIDENT) with patched_roots(root, src): - found = [p.name for p in _project_registry_files()] - assert found == ["ALPHA_REGISTRY.json", "BETA_REGISTRY.json"] + emails = [c["email"] for c in active_citizens()] + assert emails == ["@testbranch", "@alpha", "@beta"] def test_dot_prefixed_project_refused_by_the_dot_filter_alone(self, core_only): """Depth-legal and would otherwise be discovered — only the dot filter refuses it.""" root, src = core_only make_project(root, ".archive", branch="parked", residency=RESIDENCY_RESIDENT) with patched_roots(root, src): - assert _project_registry_files() == [] assert [c["email"] for c in active_citizens()] == ["@testbranch"] def test_nested_registry_refused_by_the_depth_rule_alone(self, core_only): @@ -560,15 +645,18 @@ def test_nested_registry_refused_by_the_depth_rule_alone(self, core_only): json.dumps({"branches": [{"name": "DEEP", "email": "@deep", "path": "src/deep", "status": "active"}]}) ) with patched_roots(root, src): - assert _project_registry_files() == [] assert [c["email"] for c in active_citizens()] == ["@testbranch"] - def test_missing_projects_tree_returns_empty(self, temp_src_aipass, sample_registry): + def test_missing_projects_tree_is_not_an_error(self, temp_src_aipass, sample_registry): + """A checkout with no projects/ yields the core citizens and does not raise. + + CI runs on exactly that tree — projects/ is gitignored. + """ root, src = temp_src_aipass (root / "AIPASS_REGISTRY.json").write_text(json.dumps(sample_registry)) (src / "testbranch").mkdir() with patched_roots(root, src): - assert _project_registry_files() == [] + assert [c["email"] for c in active_citizens()] == ["@testbranch"] def test_never_a_passport_walk(self, core_only): """A backup copy of a passport is still a passport — and must not be a citizen. @@ -610,46 +698,39 @@ def test_both_keys_present_joins_the_fleet(self, core_only): def test_registry_alone_is_refused_when_no_passport(self, core_only): root, src = core_only make_project(root, "proj", residency=None) - with patched_roots(root, src), patch(f"{DISCOVERY}.logger") as log: + with patched_roots(root, src): emails = [c["email"] for c in active_citizens()] assert emails == ["@testbranch"] - text = error_text(log) - assert "PROJ" in text and "PROJ_REGISTRY.json" in text and "no residency" in text def test_absent_residency_field_is_refused_and_named(self, core_only): root, src = core_only branch_dir = make_project(root, "proj", residency=None) write_passport(branch_dir, citizen_class="specialist") - with patched_roots(root, src), patch(f"{DISCOVERY}.logger") as log: + with patched_roots(root, src): emails = [c["email"] for c in active_citizens()] assert emails == ["@testbranch"] - assert "no residency" in error_text(log) def test_unreadable_passport_is_refused_and_named(self, core_only): root, src = core_only branch_dir = make_project(root, "proj", residency=None) write_passport(branch_dir, raw="{not json") - with patched_roots(root, src), patch(f"{DISCOVERY}.logger") as log: + with patched_roots(root, src): emails = [c["email"] for c in active_citizens()] assert emails == ["@testbranch"] - assert "no residency" in error_text(log) def test_core_claimed_from_inside_projects_is_refused_and_named(self, core_only): root, src = core_only make_project(root, "proj", residency=RESIDENCY_CORE) - with patched_roots(root, src), patch(f"{DISCOVERY}.logger") as log: + with patched_roots(root, src): emails = [c["email"] for c in active_citizens()] assert emails == ["@testbranch"] - text = error_text(log) - assert RESIDENCY_CORE in text and "projects/" in text def test_unknown_residency_value_is_refused_and_names_the_value(self, core_only): root, src = core_only make_project(root, "proj", residency="tenant") - with patched_roots(root, src), patch(f"{DISCOVERY}.logger") as log: + with patched_roots(root, src): emails = [c["email"] for c in active_citizens()] assert emails == ["@testbranch"] - assert "tenant" in error_text(log) def test_registry_path_not_on_disk_is_refused_and_named(self, core_only): """A row pointing nowhere used to vanish silently — it is now named. @@ -664,10 +745,9 @@ def test_registry_path_not_on_disk_is_refused_and_named(self, core_only): (project_root / "PROJ_REGISTRY.json").write_text( json.dumps({"branches": [{"name": "PROJ", "email": "@proj", "path": "src/gone", "status": "active"}]}) ) - with patched_roots(root, src), patch(f"{DISCOVERY}.logger") as log: + with patched_roots(root, src): emails = [c["email"] for c in active_citizens()] assert emails == ["@testbranch"] - assert "does not exist" in error_text(log) def test_passport_alone_cannot_add_scope(self, core_only): """A declared resident the registry lists as inactive stays out.""" @@ -685,20 +765,18 @@ def test_passport_can_never_remove_a_core_citizen(self, temp_src_aipass, sample_ root, src = temp_src_aipass (root / "AIPASS_REGISTRY.json").write_text(json.dumps(sample_registry)) (src / "testbranch").mkdir() - with patched_roots(root, src), patch(f"{DISCOVERY}.logger") as log: + with patched_roots(root, src): emails = [c["email"] for c in active_citizens()] assert emails == ["@testbranch"] - assert log.warning.called - def test_core_citizen_declaring_resident_is_kept_and_logged(self, temp_src_aipass, sample_registry): + def test_core_citizen_declaring_resident_is_kept(self, temp_src_aipass, sample_registry): root, src = temp_src_aipass (root / "AIPASS_REGISTRY.json").write_text(json.dumps(sample_registry)) (src / "testbranch").mkdir() write_passport(src / "testbranch", residency=RESIDENCY_RESIDENT) - with patched_roots(root, src), patch(f"{DISCOVERY}.logger") as log: + with patched_roots(root, src): emails = [c["email"] for c in active_citizens()] assert emails == ["@testbranch"] - assert log.warning.called class TestParkedProjectPolicyChange: @@ -715,12 +793,9 @@ class TestParkedProjectPolicyChange: def test_parked_project_refused_by_the_passport_layer_alone(self, core_only): root, src = core_only make_project(root, "marketstand", branch="marketstand", residency=None) - with patched_roots(root, src), patch(f"{DISCOVERY}.logger") as log: - registries = [p.name for p in _project_registry_files()] + with patched_roots(root, src): emails = [c["email"] for c in active_citizens()] - assert registries == ["MARKETSTAND_REGISTRY.json"], "discovery must still SEE it" assert emails == ["@testbranch"], "classification must refuse it" - assert "MARKETSTAND" in error_text(log) def test_parked_project_fires_no_scheduled_jobs(self, core_only, sample_schedule): root, src = core_only @@ -743,15 +818,44 @@ def test_parked_project_is_not_swept_for_mail(self, core_only): class TestLiveFleet: """Assertions about THIS machine's tree. Skipped loudly where it is absent.""" + def test_the_live_fleet_is_not_empty(self): + # The floor under every loop below: an empty fleet would let each of + # them pass by iterating nothing. + assert len(active_citizens()) >= 18 + def test_every_project_citizen_declares_resident(self): - for citizen in active_citizens(): - if citizen["source"] != "aipass": - assert declared_residency(citizen["path"]) == RESIDENCY_RESIDENT, citizen + # Narrowed to projects/ deliberately, when the external tier went live + # (AIPASS_ROOTS.json blessed 2026-08-30). Externals declare NOTHING and + # must not: membership out there is PRESENCE - a passport exists in a + # declared root - and the tier label is applied by the reader, never + # claimed by the passport. Six live external citizens across four repos + # carry no residency field, and requiring one would have made a + # six-owner schema campaign the price of the feature. + citizens = [c for c in active_citizens() if c["source"].startswith(_PROJECTS_DIR_NAME + "/")] + assert citizens, "no projects/ citizen on this machine - the pin proved nothing" + for citizen in citizens: + assert declared_residency(citizen["path"]) == RESIDENCY_RESIDENT, citizen + + def test_an_external_citizen_is_labelled_external_never_core(self): + # The fence. A citizen outside AIPass home may never read as core: the + # source label is what tells a downstream lane which repo to stand in. + citizens = active_citizens() + assert citizens + for citizen in citizens: + outside = not citizen["path"].is_relative_to(REAL_REPO_ROOT) + assert outside == citizen["source"].startswith("external"), citizen def test_no_citizen_resolves_under_a_dot_prefixed_component(self): - for citizen in active_citizens(): - relative = citizen["path"].relative_to(REAL_REPO_ROOT) - assert not any(part.startswith(".") for part in relative.parts), citizen + # Compared against the citizen's OWN root, not AIPass home - an + # external path is not relative to REAL_REPO_ROOT at all, and the + # invariant being held here was never about which repo it lives in. + # The .backup/ copies this exists to keep out are equally forbidden + # in a declared root. + citizens = active_citizens() + assert citizens + for citizen in citizens: + parts = citizen["path"].parts + assert not any(part.startswith(".") for part in parts), citizen def test_parked_projects_are_absent_by_name(self): emails = {c["email"] for c in active_citizens()} diff --git a/src/aipass/daemon/tests/test_fleet_scope_delegation.py b/src/aipass/daemon/tests/test_fleet_scope_delegation.py new file mode 100644 index 000000000..493e15bd6 --- /dev/null +++ b/src/aipass/daemon/tests/test_fleet_scope_delegation.py @@ -0,0 +1,47 @@ +"""Red-first pins for FPLAN-0460: discovery delegates to registry_scope.""" + +from unittest.mock import patch + +from aipass.daemon.apps.handlers.schedule.discovery import active_citizens + +SCOPE = "aipass.daemon.apps.handlers.schedule.discovery.fleet" + + +class TestDelegatesToRegistryScope: + """The fleet definition is @memory's, consumed through their modules/ door.""" + + def test_a_branch_only_registry_scope_knows_is_discovered(self, tmp_path): + """The whole point: externals arrive with no daemon code change. + + A record fleet_branches() returns must reach active_citizens() even + though NO daemon-side registry read could have produced it — the path + is outside this repo entirely. + """ + external = tmp_path / "elsewhere" / "src" / "wren" / "wren" + external.mkdir(parents=True) + fake = [{"name": "wren", "path": external, "registry": "WREN_REGISTRY.json", "email": "@wren"}] + + with patch(f"{SCOPE}.fleet_branches", return_value=fake): + citizens = active_citizens() + + assert [c["email"] for c in citizens] == ["@wren"] + assert citizens[0]["path"] == external + assert citizens[0]["dir_name"] == "wren" + + def test_an_addressless_branch_is_refused_and_named(self, tmp_path, caplog): + """@memory keeps addressless branches; daemon cannot use them. + + Their ruling: absent email is None and the branch is KEPT, because + path-based lanes still want it. Daemon is email-addressed, so it + refuses on its own terms — but never silently. + """ + home = tmp_path / "nameless" + home.mkdir() + fake = [{"name": "nameless", "path": home, "registry": "AIPASS_REGISTRY.json", "email": None}] + + with patch(f"{SCOPE}.fleet_branches", return_value=fake): + with caplog.at_level("ERROR"): + citizens = active_citizens() + + assert citizens == [] + assert "nameless" in caplog.text diff --git a/src/aipass/daemon/tests/test_import_dead_cwd.py b/src/aipass/daemon/tests/test_import_dead_cwd.py new file mode 100644 index 000000000..95dfef492 --- /dev/null +++ b/src/aipass/daemon/tests/test_import_dead_cwd.py @@ -0,0 +1,652 @@ +# =================== AIPass ==================== +# Name: test_import_dead_cwd.py +# Description: Pins daemon imports against a dead working directory +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Every daemon module must import without a readable working directory. + +THE MECHANISM, measured on the Windows CI gate 2026-08-31 (@memory's finding): +ntpath.realpath calls the working-directory reader UNCONDITIONALLY - not only +for relative paths, the way posixpath does - and Path.resolve() routes through +it. So on Windows every module-level Path(__file__).resolve() is an import-time +working-directory read, and a process whose cwd was deleted cannot import the +module at all. inspect.stack() carries the same defect one layer down: +getmodule()'s os.path.realpath sits outside every try in that function. + +THE MASK, and why the count in this docstring is worth keeping. Before the cure +all 35 importable daemon modules died, and they all died at the SAME line - the +handlers guard's inspect.stack(). Curing the guard alone took it to 25 dead, and +those 25 all died at the SECOND mask, json_handler's _DAEMON_ROOT. A count taken +before the top mask is cured measures the mask, not the tree: measure, cure, +RE-measure. Final state 36/36 importing in both worlds. + +TWO WORLDS, AND THEY ARE NOT REDUNDANT - measured here, not assumed: + A - ntpath emulation: os.path.realpath is wrapped to read the working + directory first, then that reader is denied. + B - os.path.realpath denied outright while the working directory still + reads. + +WORLD A'S STACK OUTCOME IS PLATFORM-DEPENDENT, and the qualifier took two +corrections to get right. The deciding variable is ABSPATH: + + POSIX - posixpath.abspath calls os.getcwd() for its normalisation, so denying + getcwd kills abspath too. inspect.getmodule then dies on the way IN, at + getabsfile, and that call sits inside getmodule's own + `except (TypeError, FileNotFoundError): return None`. getmodule returns + None, inspect.stack() COMPLETES. World A cannot convict the stack half here. + WINDOWS - ntpath.abspath rides Win32 _getfullpathname and never touches + getcwd, so it SURVIVES the denial. getmodule proceeds past getabsfile and + reaches the `os.path.realpath(f)` line further down - the one outside + every try - and inspect.stack() DIES. + +The history is worth keeping because the rule came out of it. Round 4 measured +the POSIX half here and wrote it as "world A cannot convict stack" - unqualified. +@cli measured the opposite in a variant that patched abspath functional, and +@devpulse reconciled the two: same world name, different instruments. That +produced the tightening "world A AS CONSTRUCTED HERE". Then round 4 shipped and +the real Windows runner redded this very pin with its own assertion text +(windows-setup, run 33431848734) - because there was one more dimension the +sentence still did not carry. The rule now reads: AS CONSTRUCTED HERE, ON THIS +PLATFORM. A measurement is of an instrument AND a platform, and naming only the +instrument is how a true sentence travels somewhere it is false. + +The pin did exactly what it was built to do - it went red the moment the +measured behaviour stopped matching, on the platform that could show it. It is +now a TABLE rather than a single expectation; see STACK_IN_WORLD_A. +Measured 2026-08-31: denying os.getcwd also kills os.path.abspath, so +inspect.getmodule dies on its way IN, at getabsfile - and that call sits inside +`except (TypeError, FileNotFoundError): return None`. getmodule returns None, +inspect.stack() completes, and a guard still calling it would pass world A for +the wrong reason. World B leaves the working directory readable, so getabsfile +succeeds and execution reaches the `os.path.realpath(f)` line further down +getmodule - the one outside every try. That is the only world of the two that +kills inspect.stack(), and it is why the fleet recipe names both. + +The asymmetry is asserted per world below rather than papered over, and +test_world_a_cannot_convict_inspect_stack pins it so nobody collapses the two +worlds into one and quietly loses the stack half of the cure. + +Both injections happen in a CHILD process before any aipass import, so no module +has cached the real functions, and the child rides a frame (python -c). + is load-bearing and NOT interchangeable with : linecache caches + content, so a probe can resolve names the real defect cannot and +report green on unfixed code (@hooks' finding). + +STACK_DIES / PROBE_ARMED runs before any assertion. Where the interpreter's own +pathlib never routes resolve() through os.path.realpath (3.10 resolves absolute +paths without touching the working directory), the denial cannot fire and the +probe says so - the pin still asserts the imports succeed there, it just proves +less. Pinned as a probe with both outcomes, never a skipif: the vacuous world is +named in the output, and vacuity is asserted to occur only on interpreters where +it is the truth. +""" + +import ast +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +# Other branches' import-time code is held CONSTANT: preloaded in the healthy +# world, before any denial. Their dead-cwd cure is their own build (fleet +# rollout in flight, 2026-08-31); this pin measures daemon's sites only. When +# the fleet is cured these preloads can drop. +PRELOAD = """ +import aipass.prax # noqa: F401 +import aipass.prax.apps.modules.logger # noqa: F401 +import aipass.cli.apps.modules # noqa: F401 +import aipass.cli.apps.modules.display # noqa: F401 +""" + +# The denial, world A: what ntpath.realpath does on Windows. +WORLD_A = """ +import os + +_real_realpath = os.path.realpath + + +def _ntpath_condition(path, **kw): + os.getcwd() # ntpath.realpath reads cwd before checking absoluteness + return _real_realpath(path, **kw) + + +os.path.realpath = _ntpath_condition + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd +""" + +# The denial, world B: realpath itself is gone, abspath still works. +WORLD_B = """ +import os + + +def _denied_realpath(*args, **kwargs): + raise FileNotFoundError(2, "realpath denied (no working directory)", "") + + +os.path.realpath = _denied_realpath +""" + +# Does THIS interpreter's resolve() reach the denied call for an absolute path? +# 3.11+ routes through os.path.realpath; 3.10 resolves absolute paths without +# the working directory, so the denial cannot fire there. +PROBE = """ +import pathlib +import sys + +try: + pathlib.Path(pathlib.__file__).resolve() + sys.stdout.write("PROBE_VACUOUS\\n") +except FileNotFoundError: + sys.stdout.write("PROBE_ARMED\\n") +""" + +# inspect.stack() must be observed dying in this world, or a guard that still +# called it would pass for the wrong reason. This is the negative control FOR +# the positive control: an instrument that cannot kill the old shape proves +# nothing about the new one. +STACK_DIES = """ +import inspect +import sys + + +def _pseudo_frame_probe(): + return inspect.stack() + + +try: + _pseudo_frame_probe() + sys.stdout.write("STACK_LIVES\\n") +except OSError: + sys.stdout.write("STACK_DIES\\n") +""" + +IMPORTS = """ +import sys + +import aipass.daemon.apps.handlers # noqa: F401 +import aipass.daemon.apps.handlers.module_root # noqa: F401 +import aipass.daemon.apps.handlers.json.json_handler # noqa: F401 +import aipass.daemon.apps.handlers.schedule.discovery # noqa: F401 +import aipass.daemon.apps.handlers.schedule.runstate # noqa: F401 +import aipass.daemon.apps.handlers.schedule.rotation # noqa: F401 +import aipass.daemon.apps.handlers.schedule.telegram_notifier # noqa: F401 +import aipass.daemon.apps.handlers.monitoring.activity_collector # noqa: F401 +import aipass.daemon.apps.handlers.monitoring.inbox_scanner # noqa: F401 +import aipass.daemon.apps.handlers.monitoring.memory_health # noqa: F401 +import aipass.daemon.apps.handlers.monitoring.red_flag_detector # noqa: F401 +import aipass.daemon.apps.handlers.monitoring.report_generator # noqa: F401 +import aipass.daemon.apps.handlers.update.data_loader # noqa: F401 +import aipass.daemon.apps.modules.run # noqa: F401 +import aipass.daemon.apps.modules.rotation # noqa: F401 +import aipass.daemon.apps.modules.inbox_sweep # noqa: F401 +import aipass.daemon.apps.modules.activity_report # noqa: F401 +import aipass.daemon.apps.modules.queue # noqa: F401 +import aipass.daemon.apps.modules.update # noqa: F401 +import aipass.daemon.apps.modules.timer_install # noqa: F401 +import aipass.daemon.apps.modules.wakeup_ops # noqa: F401 +import aipass.daemon.apps.daemon # noqa: F401 +import aipass.daemon.apps.daemon_wakeup # noqa: F401 + +sys.stdout.write("IMPORTED\\n") +""" + +# Whether world A's denial reaches the unguarded realpath inside getmodule, by +# platform. Keyed on os.name because the deciding variable - which abspath +# implementation is in play - is exactly what os.name selects. +# +# PROVENANCE, stated because the two halves are not equally strong: +# "posix" is MEASURED LIVE by this file on every POSIX run. If it is wrong, +# this test fails here, today. +# "nt" is DERIVED FROM A CI FAILURE - windows-setup run 33431848734, where the +# previous single-expectation form of this pin failed on the Windows +# runner carrying its own assertion text. That red IS the Windows +# measurement; no one has stepped through ntpath by hand. It becomes +# measured-live the first time this file runs green on a Windows box. +STACK_IN_WORLD_A = { + "posix": "STACK_LIVES", + "nt": "STACK_DIES", +} + +_BRANCH = Path(__file__).resolve().parents[1] +APPS = _BRANCH / "apps" + +# tools/ is swept too, and that is a correction rather than completeness for its +# own sake. Round 4 named tools/verify_branch.py as a deliberate EXCLUSION - the +# reasoning was that nothing imports it, so it cannot take an import down, and +# that reasoning still holds. What it missed is that the file is a TOOL a person +# runs, and a tool that dies on its own first line because the working directory +# is gone is broken exactly when someone is trying to diagnose something. +# @spawn measured it and found five branches carrying five different md5s of +# this file - pasted, never distributed, so there is no upstream to fix. +# A sweep scoped to apps/ could never have caught it. +# +# tools/ IS GITIGNORED (.gitignore:57, a blanket `tools/` rule), so it exists on +# a developer machine and NOT in a fresh clone. The roots list therefore names +# it unconditionally - that is a fact about what this branch sweeps - while the +# sweep itself skips roots that are not on disk. Requiring the directory to +# EXIST would be a pin that encodes today's machine and goes red on CI, which is +# a species this branch has shipped before (S51: two live-fleet pins broke the +# hour the fleet grew). +SWEPT_ROOTS = (APPS, _BRANCH / "tools") + + +def _sweepable(root: Path): + """Python files under *root*, or nothing if the root is not on this machine.""" + if not root.is_dir(): + return [] + return [f for f in sorted(root.rglob("*.py")) if ".archive" not in f.parts] + + +def _run_world(denial: str) -> subprocess.CompletedProcess: + """Run the import set in a child whose frame is , never .""" + source = PRELOAD + denial + PROBE + STACK_DIES + IMPORTS + return subprocess.run( + [sys.executable, "-c", source], + capture_output=True, + text=True, + timeout=120, + ) + + +def _assert_world(result: subprocess.CompletedProcess, label: str, *, expect_stack: str) -> str: + """Assert the imports survived, and that the world behaved exactly as tabled. + + expect_stack is the EXACT token required, not a boolean, and both directions + are asserted. That is a fix for a surviving mutant: with a boolean, only the + STACK_DIES direction was ever checked, so neutering the table comparison left + the whole file green on POSIX - the platform whose entry is STACK_LIVES. An + expectation that can only fail one way is half an expectation. + + Args: + result: The finished child process. + label: "A" or "B", for the failure message. + expect_stack: "STACK_DIES" or "STACK_LIVES" - what inspect.stack() must + do in this world on THIS platform. Measured per world and per + platform, never assumed; see STACK_IN_WORLD_A. + + Returns: + The child's stdout, so a caller can assert on the parts it owns. + """ + assert expect_stack in ("STACK_DIES", "STACK_LIVES"), expect_stack + out = result.stdout + assert "IMPORTED" in out, ( + f"world {label}: import died under the dead-cwd world:\nstdout={out}\nstderr={result.stderr}" + ) + + if "PROBE_VACUOUS" in out: + # Allowed only where it is the interpreter's truth (pre-3.11 pathlib + # never routes an absolute resolve through os.path.realpath). + assert sys.version_info < (3, 11), ( + f"world {label}: resolve() survived the denial on an interpreter that " + "routes through os.path.realpath - the instrument is broken, not the world" + ) + return out + + assert "PROBE_ARMED" in out, f"world {label}: probe reported neither outcome: {out}" + + # The negative control FOR the positive control, asserted in BOTH directions. + # STACK_DIES missing where expected means the world cannot convict the shape + # the guard was cured of, and every import assertion above it is vacuous. + # STACK_LIVES missing where expected means the world became more hostile than + # it was measured to be, and the table is now describing something else. + observed = "STACK_DIES" if "STACK_DIES" in out else "STACK_LIVES" if "STACK_LIVES" in out else None + assert observed is not None, f"world {label}: the stack probe reported neither outcome:\n{out}" + assert observed == expect_stack, ( + f"world {label} on os.name={os.name!r}: inspect.stack() {observed}, table " + f"says {expect_stack}. If {expect_stack} was STACK_DIES, this world can no " + "longer convict the cured shape and the import assertions above are " + "vacuous; if it was STACK_LIVES, the world got more hostile than measured. " + "Either way: re-measure both worlds on this platform before trusting either." + ) + return out + + +def test_daemon_imports_survive_world_a_ntpath_emulation(): + """The resolve half: every module-level Path(__file__).resolve() is denied. + + Whether this world ALSO convicts inspect.stack() depends on the platform - + see STACK_IN_WORLD_A and the module docstring. It is kept on both platforms + because it is the only one of the two that denies the working-directory READ + itself, which is the condition a deleted cwd actually creates. + """ + _assert_world(_run_world(WORLD_A), "A", expect_stack=STACK_IN_WORLD_A[os.name]) + + +def test_daemon_imports_survive_world_b_realpath_denied(): + """The stack half, and the one world that convicts it on EVERY platform. + + World B leaves the working directory readable and denies os.path.realpath + outright, so abspath succeeds on posixpath and ntpath alike and getmodule + always reaches the unguarded realpath. That platform-independence is why it + is the load-bearing instrument for the stack half, and why world A's + per-platform table below is a fact to record rather than a gap to fill. + """ + _assert_world(_run_world(WORLD_B), "B", expect_stack="STACK_DIES") + + +def test_world_a_stack_outcome_matches_the_platform_table(): + """Pin world A's stack outcome to the table, so the two worlds stay distinct. + + This test earned its keep: in its previous single-expectation form it went + RED on the Windows CI runner carrying its own assertion text, which is how + the platform dimension was found at all. The table is the fix; the pin is + unchanged in purpose. + + A red here means the observed behaviour left the table on THIS platform. + The honest response is to re-measure both worlds on this platform and + correct the entry - never to delete world B, and never to widen the + assertion until it cannot fail. Which entry is measured live and which is + derived from CI is recorded on STACK_IN_WORLD_A itself. + """ + expected = STACK_IN_WORLD_A.get(os.name) + if expected is None: + pytest.skip(f"no measured world-A stack outcome for os.name={os.name!r} - add one rather than guessing") + + # The comparison itself lives in _assert_world, which asserts the exact + # token in BOTH directions. A second copy here was removed rather than kept: + # a mutant proved it unkillable, and an assertion no mutant can kill is + # decoration that makes the file look better covered than it is. + _assert_world(_run_world(WORLD_A), "A", expect_stack=expected) + + +def test_the_platform_table_covers_the_platforms_this_fleet_runs_on(): + """A table with a hole reads green by skipping - so the hole is a test. + + AIPass CI runs Linux, macOS (both os.name == 'posix') and Windows + ('nt'). If either key disappears, the skip above would quietly retire the + pin on the platform whose CI failure created the table in the first place. + """ + assert set(STACK_IN_WORLD_A) == {"posix", "nt"}, STACK_IN_WORLD_A + assert set(STACK_IN_WORLD_A.values()) <= {"STACK_LIVES", "STACK_DIES"}, STACK_IN_WORLD_A + assert os.name in STACK_IN_WORLD_A, ( + f"this machine is os.name={os.name!r} and the table has no entry - the pin is skipping rather than measuring" + ) + + +# --------------------------------------------------------------------------- +# The structural pin. @trigger measured why this is required: restoring the +# deleted inspect.stack() walk left 1058 tests green, because the +# caller-is-None branch it sat in is unreachable from any import-shaped world. +# Only a parse of the file convicts it. +# --------------------------------------------------------------------------- + + +def _inspect_stack_calls(source: str) -> list: + """Line numbers of every inspect.stack() call in *source*, by parse. + + An AST matcher, never a string scan: the guard's own docstring NAMES + inspect.stack() while explaining the defect, and a spelling ban would + convict the explanation while acquitting the code - which is how a cure + ends up undocumented. + """ + tree = ast.parse(source) + return [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "stack" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "inspect" + ] + + +class TestTheGuardNeverCallsInspectStack: + def test_the_guard_is_free_of_inspect_stack(self): + guard = (APPS / "handlers" / "__init__.py").read_text(encoding="utf-8") + + assert _inspect_stack_calls(guard) == [], ( + "inspect.stack() is back in the guard - it needs a readable working " + "directory on Windows before any of the guard's own code runs " + "(getmodule's os.path.realpath sits outside any try)" + ) + assert "import inspect" not in [line.strip() for line in guard.splitlines()], ( + "the guard imports inspect again - nothing in the cured shape needs it" + ) + + def test_the_json_handler_caller_walk_is_free_of_inspect_stack(self): + """The runtime half. log_operation runs on essentially every tick.""" + handler = (APPS / "handlers" / "json" / "json_handler.py").read_text(encoding="utf-8") + + assert _inspect_stack_calls(handler) == [], ( + "inspect.stack() is back in _get_caller_module_name - the audit line " + "would take its own caller down on a Windows box with no working directory" + ) + + def test_no_daemon_module_calls_inspect_stack(self): + """The sweep, so the next copy of the template line is caught at birth.""" + offenders = [] + for root in SWEPT_ROOTS: + for source in _sweepable(root): + for lineno in _inspect_stack_calls(source.read_text(encoding="utf-8")): + offenders.append(f"{source.relative_to(_BRANCH)}:{lineno}") + + assert offenders == [], "inspect.stack() calls in daemon: " + ", ".join(offenders) + + def test_the_matcher_convicts_a_real_call(self): + """Positive control - a ban that convicts nothing reads green forever.""" + assert _inspect_stack_calls("import inspect\nframes = inspect.stack()\n") == [2] + + def test_the_matcher_clears_prose_that_names_the_defect(self): + """Docstring negative control - the red that would rewrite the cure's own comments.""" + assert _inspect_stack_calls('"""inspect.stack() is banned here."""\n') == [], ( + "a docstring explaining the cure is not the defect; convicting it is how cures go unexplained" + ) + + def test_the_matcher_clears_an_unrelated_stack_attribute(self): + """numpy.stack shape - the attribute name alone must not convict.""" + assert _inspect_stack_calls("import numpy\nnumpy.stack([])\n") == [] + + def test_the_matcher_clears_inspect_signature(self): + """rotation.py legitimately calls inspect.signature; only .stack is banned.""" + assert _inspect_stack_calls("import inspect\ninspect.signature(f)\n") == [] + + +# --------------------------------------------------------------------------- +# The resolve half of the same species, banned at parse level for the same +# reason: a module-level Path(__file__).resolve() is an import-time +# working-directory read, and no import-shaped pin can name WHICH line did it. +# --------------------------------------------------------------------------- + + +def _module_level_resolves(source: str) -> list: + """Lines calling .resolve() at MODULE level (outside any def/class). + + Only module level: a resolve inside a function is reached when the function + is called, and module_file() itself must be allowed to make the call it + exists to guard. + """ + tree = ast.parse(source) + nested = set() + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + for child in ast.walk(node): + nested.add(id(child)) + + return [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "resolve" + and id(node) not in nested + ] + + +class TestNoModuleLevelResolveSurvives: + def test_every_module_level_resolve_routes_through_module_file(self): + offenders = [] + for root in SWEPT_ROOTS: + for source in _sweepable(root): + for lineno in _module_level_resolves(source.read_text(encoding="utf-8")): + offenders.append(f"{source.relative_to(_BRANCH)}:{lineno}") + + assert offenders == [], "module-level .resolve() outside module_file(): " + ", ".join(offenders) + + def test_the_roots_list_still_names_tools(self): + """The roots list is a fact about the code, so it is pinned like one. + + The apps/-only scope is exactly what let tools/verify_branch.py stand + uncured through round 4. This goes red if tools/ is dropped from the + sweep - which is a code change someone makes - and stays green when the + directory is merely absent, which is what a fresh clone looks like, + because tools/ is gitignored. + """ + assert APPS in SWEPT_ROOTS, "apps/ dropped out of the sweep" + assert _BRANCH / "tools" in SWEPT_ROOTS, "tools/ dropped out of the sweep" + + def test_the_sweep_reads_tools_when_it_is_present(self): + """And when the directory IS here, it must actually be read. + + Skipped rather than failed where tools/ is absent: on CI that absence is + the truth (gitignored), and a pin that fails for it would be asserting a + fact about the machine. The skip states which world it saw. + """ + tools = _BRANCH / "tools" + if not tools.is_dir(): + pytest.skip("tools/ is not on this machine - gitignored, so a fresh clone has none") + + assert _sweepable(tools), "tools/ is present but the sweep reads no Python from it" + + def test_the_matcher_convicts_the_shape_that_was_cured(self): + """Positive control - the exact pre-cure line, from json_handler.py:35.""" + assert _module_level_resolves( + "from pathlib import Path\n_DAEMON_ROOT = Path(__file__).resolve().parents[3]\n" + ) == [2] + + def test_the_matcher_clears_a_resolve_inside_a_function(self): + """module_file()'s own guarded call must not be convicted.""" + assert _module_level_resolves("from pathlib import Path\n\n\ndef f(p):\n return Path(p).resolve()\n") == [] + + def test_the_matcher_clears_prose_that_names_the_defect(self): + assert _module_level_resolves('"""Path(__file__).resolve() is the defect."""\n') == [] + + +# --------------------------------------------------------------------------- +# module_file()'s own contract. Added because a mutant SURVIVED: forcing the +# fallback on every call left all fourteen pins above green. Every one of them +# asks "did the import survive", and the unresolved spelling imports perfectly +# well - so nothing was measuring the reason .resolve() is called at all. A +# permanently-degraded resolver would have shipped silently. +# --------------------------------------------------------------------------- + + +class TestModuleFileResolvesWhenItCan: + def test_a_healthy_world_gets_a_resolved_path(self, tmp_path): + """The symlink is the point: resolving is why the call exists.""" + from aipass.daemon.apps.handlers.module_root import module_file + + real = tmp_path / "real" + real.mkdir() + target = real / "mod.py" + target.write_text("", encoding="utf-8") + link = tmp_path / "link.py" + link.symlink_to(target) + + assert module_file(str(link)) == target.resolve() + + def test_the_fallback_is_reached_only_on_oserror(self, tmp_path, monkeypatch): + """And when reached, it still names the right file - absolutely.""" + from aipass.daemon.apps.handlers import module_root + + target = tmp_path / "mod.py" + target.write_text("", encoding="utf-8") + + def denied(self, *args, **kwargs): + raise OSError(2, "no working directory") + + monkeypatch.setattr(Path, "resolve", denied) + + result = module_root.module_file(str(target)) + + assert result == target + assert result.is_absolute(), "the fallback returned a path that names nowhere in particular" + + def test_the_fallback_never_raises_when_the_diagnostic_lane_is_down(self, tmp_path, monkeypatch): + """The world that denies the filesystem can deny the logger too. + + This pin found a real defect: the first cut called logger.debug from + module_file() itself, outside every try, so a failing logger turned the + survivable import into a crash - inside the guard written to prevent + exactly that crash. + """ + from aipass.daemon.apps.handlers import module_root + + target = tmp_path / "mod.py" + target.write_text("", encoding="utf-8") + + def denied(self, *args, **kwargs): + raise OSError(2, "no working directory") + + raising = _RaisingLogger() + monkeypatch.setattr(Path, "resolve", denied) + monkeypatch.setattr(module_root, "logger", raising) + + # Control: the stand-in really does raise, so a green below is not green + # because the injection did nothing. + with pytest.raises(RuntimeError): + module_root.logger.debug("probe") + + result = module_root.module_file(str(target)) + + assert result == target + assert raising.calls, "the diagnostic lane was never reached - the pin proved nothing" + + def test_the_fallback_never_raises_when_the_audit_write_fails(self, tmp_path, monkeypatch): + """json_handler unavailable is the other half of the same bare world.""" + from aipass.daemon.apps.handlers import module_root + + target = tmp_path / "mod.py" + target.write_text("", encoding="utf-8") + + def denied(self, *args, **kwargs): + raise OSError(2, "no working directory") + + def exploding_import(*args, **kwargs): + raise RuntimeError("the audit lane is down too") + + monkeypatch.setattr(Path, "resolve", denied) + monkeypatch.setattr(module_root, "__import__", exploding_import, raising=False) + + from aipass.daemon.apps.handlers.json import json_handler + + def exploding_log(*args, **kwargs): + raise RuntimeError("the audit lane is down too") + + monkeypatch.setattr(json_handler, "log_operation", exploding_log) + + with pytest.raises(RuntimeError): + json_handler.log_operation("probe", {}) + + assert module_root.module_file(str(target)) == target + + +class _RaisingLogger: + """A logger whose every call fails - the worst world module_file can meet. + + Counts its calls, so a test can tell "the diagnostic lane was exercised and + swallowed" apart from "the diagnostic lane was never reached". + """ + + def __init__(self): + self.calls = [] + + def _fail(self, *args, **kwargs): + self.calls.append(args) + raise RuntimeError("logging is down") + + debug = _fail + warning = _fail + info = _fail + error = _fail diff --git a/src/aipass/daemon/tests/test_json_log_dir_seam.py b/src/aipass/daemon/tests/test_json_log_dir_seam.py new file mode 100644 index 000000000..cc698c557 --- /dev/null +++ b/src/aipass/daemon/tests/test_json_log_dir_seam.py @@ -0,0 +1,259 @@ +# =================== AIPass ==================== +# Name: test_json_log_dir_seam.py +# Description: AIPASS_TEST_LOG_DIR adoption pins for daemon's json_handler +# Version: 1.0.0 +# Created: 2026-08-30 +# Modified: 2026-08-30 +# ============================================= + +""" +The fleet AIPASS_TEST_LOG_DIR contract, adopted in daemon's own json_handler. + +@prax ruled the contract (mail 01fb09c6) after measuring that the env-var +branch ALONE is not enough: daemon's own conftest sets AIPASS_TEST_LOG_DIR at +module scope, but something imports json_handler before conftest runs, so a +value captured at import time loses the race and resolves to the live tree. +A seam that depends on winning an import race is not a seam. + +Every pin here was red before the fix and names which part of the contract it +holds, so a mutation that reverts one detail dies by name rather than by count. +""" + +import os +import sys + +import pytest + +from aipass.daemon.apps.handlers.json import json_handler as jh + + +class TestTheRedirectResolvesAtCallTime: + """The defect @prax found the hard way: import-time capture loses.""" + + def test_an_env_set_after_import_still_redirects(self, tmp_path, monkeypatch): + # The whole point. json_handler is ALREADY imported by the time this + # runs, exactly as it is already imported when conftest sets the var. + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + + path = jh.get_json_path("some_module", "log") + + assert tmp_path in path.parents, f"resolved to {path}, not under {tmp_path}" + + def test_the_layout_matches_the_fleet_contract(self, tmp_path, monkeypatch): + # //_json/ — @trigger's form, so a shared root + # holding several branches' redirects never collides. + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + + path = jh.get_json_path("some_module", "log") + + assert path == tmp_path / "daemon" / "daemon_json" / "some_module_log.json" + + def test_a_second_call_follows_a_changed_env(self, tmp_path, monkeypatch): + # Resolution is per-call, not cached on first use: a memoised resolver + # would pass the first pin above and still be import-time in disguise. + first = tmp_path / "first" + second = tmp_path / "second" + + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(first)) + before = jh.get_json_path("some_module", "log") + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(second)) + after = jh.get_json_path("some_module", "log") + + assert first in before.parents + assert second in after.parents + + +class TestAnEmptyValueIsAbsenceNotARedirect: + """@prax's detail 1 — Path('') / 'x' is RELATIVE and scatters state.""" + + @pytest.mark.parametrize("blank", ["", " "]) + def test_a_blank_env_falls_back_to_the_real_directory(self, blank, monkeypatch): + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", blank) + + path = jh.get_json_path("some_module", "log") + + assert path.is_absolute() + assert path.parent == jh._IMPORT_TIME_JSON_DIR + + def test_an_unset_env_falls_back_to_the_real_directory(self, monkeypatch): + monkeypatch.delenv("AIPASS_TEST_LOG_DIR", raising=False) + + path = jh.get_json_path("some_module", "log") + + assert path.parent == jh._IMPORT_TIME_JSON_DIR + + +class TestAnExplicitPatchBeatsTheEnv: + """@prax's detail 2 — ~20 existing daemon tests redirect by setattr.""" + + def test_a_patched_module_attribute_wins_over_the_env(self, tmp_path, monkeypatch): + patched = tmp_path / "patched" + env_dir = tmp_path / "env" + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(env_dir)) + monkeypatch.setattr(jh, "JSON_DIR", patched) + + path = jh.get_json_path("some_module", "log") + + assert path == patched / "some_module_log.json" + + def test_a_patch_given_as_a_string_is_honoured(self, tmp_path, monkeypatch): + # test_contracts.py and test_error_resilience.py both patch with str(), + # not Path() — the resolver coerces rather than returning a str and + # exploding at the first `/`. + patched = tmp_path / "patched" + monkeypatch.setattr(jh, "JSON_DIR", str(patched)) + + path = jh.get_json_path("some_module", "log") + + assert path == patched / "some_module_log.json" + + +class TestTheUseSitesResolveTooNotJustTheResolver: + """@prax's detail 3 — the mutation that survived every other test they had. + + Reverting a path builder to read the import-time constant is invisible to a + resolver-only pin, so each writing entry point is held here directly. + """ + + def test_ensure_json_exists_creates_under_the_redirect(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + + assert jh.ensure_json_exists("probe_module", "log") is True + + assert (tmp_path / "daemon" / "daemon_json" / "probe_module_log.json").exists() + + def test_save_json_writes_under_the_redirect(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + + assert jh.save_json("probe_module", "log", [{"operation": "x"}]) is True + + target = tmp_path / "daemon" / "daemon_json" / "probe_module_log.json" + assert target.exists() + + def test_load_json_reads_under_the_redirect(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + jh.save_json("probe_module", "log", [{"operation": "written"}]) + + assert jh.load_json("probe_module", "log") == [{"operation": "written"}] + + def test_log_operation_writes_under_the_redirect(self, tmp_path, monkeypatch): + # The residual @prax named for daemon by name: rotation_log.json and + # friends are module-name-derived, so they are OURS, not prax's. + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + + assert jh.log_operation("probe_op", {"k": "v"}, module_name="rotation") is True + + assert (tmp_path / "daemon" / "daemon_json" / "rotation_log.json").exists() + + +class TestNothingReachesTheLiveTree: + """The outcome the contract exists for, asserted as behaviour not as path. + + Recorded rather than blocked: @backup's lesson from the credentials watch — + a hook that raises can drop a library into a worse path than the write. + """ + + def test_a_full_log_cycle_touches_no_file_in_the_real_daemon_json(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + live = str(jh._IMPORT_TIME_JSON_DIR) + touched: list = [] + + def watch(event, args): + if event not in ("open", "os.mkdir", "os.rename", "os.remove"): + return + try: + if event == "open": + mode = str(args[1] or "") + if "w" not in mode and "a" not in mode and "x" not in mode: + return + target = os.path.abspath(str(args[0])) + except Exception: + return + if target.startswith(live): + touched.append((event, target)) + + sys.addaudithook(watch) + + jh.ensure_module_jsons("probe_module") + jh.log_operation("probe_op", {"k": "v"}, module_name="probe_module") + jh.increment_counter("probe_module", "probe_counter") + + assert touched == [], f"writes reached the live tree: {touched[:5]}" + + +class TestSaveJsonCreatesItsOwnDirectory: + """Found BY the redirect, not by review — so it gets its own name. + + save_json was the only writer that never mkdir'd. It passed for years + because the live daemon_json/ is committed and therefore always there; + point the handler anywhere else and _atomic_write_json's tempfile raises + FileNotFoundError before a byte is written. + """ + + def test_save_json_creates_a_missing_directory_rather_than_raising(self, tmp_path, monkeypatch): + missing = tmp_path / "never" / "existed" + monkeypatch.setattr(jh, "JSON_DIR", missing) + assert not missing.exists() + + assert jh.save_json("probe_module", "log", [{"operation": "x"}]) is True + + assert (missing / "probe_module_log.json").exists() + + +class TestTheRedirectSurvivesAModuleReload: + """@prax's recipe compares JSON_DIR by IDENTITY. That breaks here. + + test_contracts.py calls importlib.reload(json_handler) while its autouse + monkeypatch is active. Teardown then writes the PRE-reload Path object back + onto the POST-reload module, so JSON_DIR ends up EQUAL to the default and + not IDENTICAL to it. Under an identity check every later call reads + "explicitly patched" and the redirect silently stops working for the rest + of the session -- 9 of these pins went red in the full suite while passing + alone. Comparing by value is what makes it survive. + """ + + def test_a_reload_does_not_disable_the_env_redirect(self, tmp_path, monkeypatch): + import importlib + + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + # Reproduce the exact sequence: patch, reload underneath the patch, + # then restore the object captured before the reload. + before = jh.JSON_DIR + jh.JSON_DIR = tmp_path / "transient" + try: + importlib.reload(jh) + finally: + jh.JSON_DIR = before + + assert jh.JSON_DIR == jh._IMPORT_TIME_JSON_DIR + assert jh.JSON_DIR is not jh._IMPORT_TIME_JSON_DIR, "the reload signature this pin exists for did not reproduce" + assert jh.get_json_path("some_module", "log") == (tmp_path / "daemon" / "daemon_json" / "some_module_log.json") + + def test_the_reverse_ordering_prax_reported_also_survives(self, tmp_path, monkeypatch): + """@prax's follow-up (mail 80088f4a): import FIRST, env set AFTERWARDS. + + Their correction is right about prax and wrong about daemon, and the + difference is structural rather than lucky. prax's import-time constant + is ITSELF env-derived, so it takes a different VALUE across a reload + that straddles the env being set - which is why comparing against it + goes stale whichever operator you use. Daemon's _IMPORT_TIME_JSON_DIR + is never env-derived: it is the real directory and nothing else, so it + recomputes to the same value every reload and is already a fixed point. + Pinned so a later refactor cannot quietly make it env-derived. + """ + import importlib + + monkeypatch.delenv("AIPASS_TEST_LOG_DIR", raising=False) + importlib.reload(jh) + before = jh.JSON_DIR + jh.JSON_DIR = tmp_path / "transient" + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path / "redirect")) + try: + importlib.reload(jh) + finally: + jh.JSON_DIR = before + + assert jh._IMPORT_TIME_JSON_DIR == before, ( + "the import-time default became env-derived - the prax failure now applies here" + ) + assert jh.get_json_path("m", "log") == (tmp_path / "redirect" / "daemon" / "daemon_json" / "m_log.json") diff --git a/src/aipass/daemon/tests/test_next_run_agrees_with_due.py b/src/aipass/daemon/tests/test_next_run_agrees_with_due.py new file mode 100644 index 000000000..906275017 --- /dev/null +++ b/src/aipass/daemon/tests/test_next_run_agrees_with_due.py @@ -0,0 +1,191 @@ +# =================== AIPass ==================== +# Name: test_next_run_agrees_with_due.py +# Description: next_run must name an instant is_job_due actually agrees with +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""One question, one answer: next_run and is_job_due must not disagree. + +FOUND LIVE, 2026-08-31. @vera's release-watch is `daily @ 10:00`. It fired at +09:45:11 - correct, that is the leading edge of the +/-15min window - and the +runstate then advertised next_run = 2026-08-31T10:00:00. Nine minutes away, and +a fire that could never happen: _already_ran_today consumes the whole CALENDAR +DAY, so is_job_due answered False at 09:55, 10:00, 10:05 and 10:14, and True +only on 2026-09-01. + +THE MECHANISM: _calc_next_run is HANDED last_run_ts and, for daily/rotation and +hourly, ignores it and reads the wall clock instead. It then asks a different +question - "when does the target time next come round" - than the one the +scheduler asks - "which period has already been consumed". Two implementations +of one contract, which is the species that has bitten this branch before (S51: +a guard that re-derived what it policed instead of consulting it). + +INTERVAL WAS ALREADY RIGHT, and that is the tell: it is the one branch that uses +the timestamp it was given. The bug is not "daily is hard", it is "two branches +reached for now() when the answer was in the argument list". + +WHY IT MATTERS even though nothing schedules off next_run: `drone @daemon queue` +is the human status surface. An operator reading NEXT RUN 10:00 at 09:52 expects +a wake in eight minutes. When it does not come, the field that lied is the first +place they will look for the reason - so it points the investigation the wrong +way at exactly the moment someone is already debugging a missed wake. + +THE PIN IS THE AGREEMENT, not a restatement of the arithmetic. Asserting +"daily -> tomorrow" would be a third implementation. These tests drive +is_job_due at the instant next_run names and require it to say True - and +require False at every instant strictly before it, on the same window grid. +""" + +from datetime import datetime, timedelta + +import pytest + +from aipass.daemon.apps.handlers.schedule import runstate + + +def _state_after_firing(schedule: dict, fired_at: str) -> dict: + """A runstate holding exactly one job, fired at *fired_at*.""" + rs = {"version": 1, "jobs": {}} + runstate.update_job_runstate(rs, "@probe", "job", schedule, timestamp=fired_at) + return rs + + +def _job(schedule: dict) -> dict: + return {"owner": "@probe", "id": "job", "schedule": schedule, "prompt": "x", "enabled": True} + + +DAILY = {"type": "daily", "time": "10:00"} +ROTATION = {"type": "rotation", "time": "05:00"} +HOURLY = {"type": "hourly", "time": "0"} +INTERVAL = {"type": "interval", "interval_minutes": 60} + +# The +/-15min window is_job_due enforces for windowed schedules. Named once, +# asserted against real behaviour in test_the_window_slack_is_real_and_bounded. +WINDOW_SLACK = timedelta(minutes=15) + + +class TestNextRunNamesAnInstantThatIsActuallyDue: + @pytest.mark.parametrize( + "schedule,fired_at", + [ + # The live case: fired at the leading edge of its window. + (DAILY, "2026-08-31T09:45:11"), + # ...and the ordinary case, fired dead on target. + (DAILY, "2026-08-31T10:00:00"), + # ...and the trailing edge. + (DAILY, "2026-08-31T10:14:00"), + (ROTATION, "2026-08-31T04:46:00"), + (HOURLY, "2026-08-31T09:47:00"), + (INTERVAL, "2026-08-31T09:45:11"), + ], + ) + def test_is_job_due_agrees_at_the_advertised_instant(self, schedule, fired_at): + rs = _state_after_firing(schedule, fired_at) + next_run = rs["jobs"]["@probe/job"]["next_run"] + + assert next_run is not None, f"{schedule['type']} advertised no next run at all" + assert runstate.is_job_due(_job(schedule), rs, datetime.fromisoformat(next_run)), ( + f"{schedule['type']} fired at {fired_at} advertises next_run={next_run}, " + "but is_job_due says the job is NOT due at that instant - the queue " + "shows the operator a wake that will never come" + ) + + @pytest.mark.parametrize( + "schedule,fired_at", + [ + (DAILY, "2026-08-31T09:45:11"), + (ROTATION, "2026-08-31T04:46:00"), + (HOURLY, "2026-08-31T09:47:00"), + ], + ) + def test_the_queue_never_under_reports_by_a_whole_period(self, schedule, fired_at): + """The other half: next_run must not be LATER than the true next fire. + + Without this, returning the year 3000 would satisfy the test above. + + WINDOW_SLACK is the one allowance, and it is a decision rather than a + tolerance bolted on to make a red go green. A windowed job becomes due + at target-15min, so the earliest possible fire is always earlier than + the target time next_run reports. Reporting target-15min instead was + considered and rejected in _calc_next_run's docstring: "daily @ 10:00" + is what the owner configured and what the queue should echo, and window + arithmetic in a human-facing field helps nobody. + + So: due strictly inside the window before next_run is EXPECTED. Due + anywhere earlier than that means a whole period was skipped, which is + the defect this file exists for, and stays red. + """ + rs = _state_after_firing(schedule, fired_at) + next_run = datetime.fromisoformat(rs["jobs"]["@probe/job"]["next_run"]) + fired = datetime.fromisoformat(fired_at) + earliest_honest = next_run - WINDOW_SLACK + + probe = fired + timedelta(minutes=5) + early = [] + while probe < earliest_honest: + if runstate.is_job_due(_job(schedule), rs, probe): + early.append(probe.isoformat()) + probe += timedelta(minutes=5) + + assert early == [], ( + f"{schedule['type']} is due at {early[:3]} - more than the {WINDOW_SLACK} " + f"window before the advertised next_run={next_run.isoformat()}, so the " + "queue under-reports by a whole period" + ) + + @pytest.mark.parametrize("schedule,fired_at", [(DAILY, "2026-08-31T09:45:11"), (ROTATION, "2026-08-31T04:46:00")]) + def test_the_window_slack_is_real_and_bounded(self, schedule, fired_at): + """Control for the allowance above: it must not be free to grow. + + A tolerance nobody measures becomes a place to hide the next defect. + This asserts the job IS due somewhere inside the slack window - so the + allowance is describing real behaviour - and that WINDOW_SLACK matches + the window is_job_due actually enforces. + """ + rs = _state_after_firing(schedule, fired_at) + next_run = datetime.fromisoformat(rs["jobs"]["@probe/job"]["next_run"]) + + assert runstate.is_job_due(_job(schedule), rs, next_run - WINDOW_SLACK), ( + f"{schedule['type']} is NOT due at next_run-{WINDOW_SLACK}; the slack " + "this file allows does not describe real behaviour" + ) + assert not runstate.is_job_due(_job(schedule), rs, next_run - WINDOW_SLACK - timedelta(minutes=1)), ( + f"{schedule['type']} is due EARLIER than {WINDOW_SLACK} before target - " + "the window widened and this file's allowance is now too small to catch it" + ) + + +class TestCalcNextRunReadsItsArgumentNotTheWallClock: + """The structural half - the defect was reaching for now() at all. + + Driven by firing the SAME job at the same instant twice, with the process + clock moved in between. A function that derives its answer from the + timestamp it was handed cannot notice; the pre-fix daily and hourly + branches gave two different answers. + """ + + @pytest.mark.parametrize("schedule,fired_at", [(DAILY, "2026-08-31T09:45:11"), (HOURLY, "2026-08-31T09:47:00")]) + def test_the_answer_does_not_move_when_the_wall_clock_does(self, schedule, fired_at, monkeypatch): + first = _state_after_firing(schedule, fired_at)["jobs"]["@probe/job"]["next_run"] + + real_datetime = runstate.datetime + + class Shifted(real_datetime): + @classmethod + def now(cls, tz=None): + return real_datetime(2027, 3, 4, 23, 59, 0) + + monkeypatch.setattr(runstate, "datetime", Shifted) + + # Control: the injection really did move the process clock. + assert runstate.datetime.now().year == 2027 + + second = _state_after_firing(schedule, fired_at)["jobs"]["@probe/job"]["next_run"] + + assert first == second, ( + f"{schedule['type']} next_run changed from {first} to {second} for the " + "SAME firing instant - it is reading the wall clock instead of the " + "last_run timestamp it was given" + ) diff --git a/src/aipass/daemon/tests/test_run_blocked_contract.py b/src/aipass/daemon/tests/test_run_blocked_contract.py new file mode 100644 index 000000000..f851069a7 --- /dev/null +++ b/src/aipass/daemon/tests/test_run_blocked_contract.py @@ -0,0 +1,393 @@ +# =================== AIPass ==================== +# Name: test_run_blocked_contract.py +# Description: Blocked is not ran — a wake that never started must not consume the period +# Version: 1.0.0 +# Created: 2026-08-30 +# Modified: 2026-08-30 +# ============================================= + +""" +BLOCKED IS NOT RAN — the second face of "a failed fire consumes its period". + +Dispatched by @devpulse with the live chain measured on this machine: the 19:47 +daemon wake of @vera opened an interactive tmux room, she finished in minutes, +and the room then SAT AT THE PROMPT for 90+ minutes. wake_branch refuses to +spawn into an occupied branch, ``_fire_job`` recorded that refusal as a failure, +and ``record_job_failure`` stamped ``last_run`` — so tonight's leftover room +would have swallowed tomorrow's 10:00 fire. A scheduler whose every fire plants +the blocker for its next fire is the defect shape. + +Two halves, one contract, both pinned here: + + 1. SCHEDULED LANE. ``run.py`` passes ``scheduled=True``, so a manager target + goes headless through dispatch_monitor instead of an unattended tmux room + nobody closes. No room, so no self-blocking. rotation.py was already doing + this; run.py was the odd path out, and two lanes in one caller disagreeing + is its own defect. + + 2. BLOCKED IS NOT RAN. When the wake never STARTED — occupancy, a live + dispatch lock, autonomous_pause, a lock we could not take — the job stays + DUE and retries on later ticks inside its window. ``last_run`` is only + stamped by a wake that actually started. + +The bound is stated, not omitted: a blocked fire buys a short retry hold +(``_BLOCKED_RETRY_MINUTES``). Removing a suppression has to be replaced by a +bound rather than by nothing — an INTERVAL job measures from its last ATTEMPT, +so a blocked-forever interval job with no hold would re-attempt on every +~2-minute tick for as long as the target stayed busy. +""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch + +import pytest + +from aipass.daemon.apps.handlers.schedule import runstate as rs +from aipass.daemon.apps.modules import run as run_mod +from aipass.daemon.apps.modules.run import ( + OUTCOME_BLOCKED, + OUTCOME_FAILED, + OUTCOME_FIRED, + _fire_job, + run_tick, +) + +RUN = "aipass.daemon.apps.modules.run" + +DAILY = {"type": "daily", "time": "10:00"} +INTERVAL = {"type": "interval", "interval_minutes": 60} + + +def _job(schedule=None, owner="@vera", job_id="release-watch"): + return { + "owner": owner, + "id": job_id, + "enabled": True, + "schedule": schedule or DAILY, + "wake": {"fresh": True}, + "prompt": "tend your branch", + } + + +class FakeStatus: + """Stand-in for ai_mail's DispatchStatus — same steps list, same reader. + + A seam, never the real dispatcher: these tests must never reach a live + wake_branch, a tmux server or another branch's lock file. + """ + + def __init__(self, steps): + self.steps = list(steps) + + def find_step(self, label): + for step in reversed(self.steps): + if step[1] == label: + return step + return None + + @property + def summary(self): + if self.steps: + _, label, detail = self.steps[-1] + return f"{label}: {detail}" + return "no status" + + +# Every terminal shape wake_branch can return with ok=False, taken from the +# gates in ai_mail's wake_branch, paired with the outcome daemon owes it. +OCCUPIED = FakeStatus( + [ + ("ok", "resolve", "@vera → /repo/vera"), + ("warn", "occupancy", "Interactive Claude session in /repo/vera"), + ("fail", "blocked", "Cannot spawn — interactive session running"), + ] +) +LOCKED = FakeStatus([("ok", "resolve", "@vera → /repo/vera"), ("fail", "lock", "Active agent (PID 4242, since 19:47)")]) +PAUSED = FakeStatus([("fail", "pause", "System paused (autonomous_pause active)")]) +LOCK_LOST = FakeStatus([("ok", "occupancy", "No interactive session"), ("fail", "lock-acquire", "Lock failed: taken")]) +NOT_FOUND = FakeStatus([("fail", "resolve", "Branch not found: @vera")]) +BLOCKLISTED = FakeStatus([("fail", "blocklist", "@devpulse is on WAKE_BLOCKLIST — refused in the scheduled lane")]) +SPAWNED = FakeStatus([("ok", "spawn", "session started")]) + + +def _fire_with(status, ok, job=None): + """Run _fire_job against a stubbed wake_branch. Returns (outcome, detail).""" + fake_wake = MagicMock(return_value=(status, ok)) + with ( + patch("aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch", fake_wake), + patch(f"{RUN}._should_notify", return_value=False), + ): + return _fire_job(job or _job(), {"jobs": {}}), fake_wake + + +# ── Half 1: the scheduled lane ─────────────────────── + + +class TestScheduledLane: + """run.py fires on a clock, so every wake it makes is a scheduled wake.""" + + def test_run_passes_scheduled_true(self): + (_outcome, _detail), fake_wake = _fire_with(SPAWNED, True) + assert fake_wake.call_args.kwargs["scheduled"] is True + + def test_scheduled_is_not_conditional_on_the_target(self): + """The flag describes the CALLER's lane, not the target's class. + + Deciding it per-target would mean reading the passport here, which is a + second copy of the manager gate wake_branch already owns. + """ + for owner in ("@vera", "@commons", "@backup"): + (_outcome, _detail), fake_wake = _fire_with(SPAWNED, True, job=_job(owner=owner)) + assert fake_wake.call_args.kwargs["scheduled"] is True, owner + + def test_sender_is_still_daemon(self): + (_outcome, _detail), fake_wake = _fire_with(SPAWNED, True) + assert fake_wake.call_args.kwargs["sender"] == "@daemon" + + +# ── Half 2: blocked is not ran ─────────────────────── + + +class TestBlockedClassification: + """Which refusals are 'never started' and which are a decided failure.""" + + @pytest.mark.parametrize( + "status", [OCCUPIED, LOCKED, PAUSED, LOCK_LOST], ids=["occupancy", "lock", "pause", "lock-acquire"] + ) + def test_gates_that_never_started_a_wake_are_blocked(self, status): + (outcome, detail), _ = _fire_with(status, False) + assert outcome == OUTCOME_BLOCKED + assert detail + + @pytest.mark.parametrize("status", [NOT_FOUND, BLOCKLISTED], ids=["resolve", "blocklist"]) + def test_decided_refusals_are_still_failures(self, status): + """A missing branch and a blocklisted target are not transient. + + Retrying either on every tick inside the window is noise: nothing about + the next two minutes changes the answer. + """ + (outcome, _detail), _ = _fire_with(status, False) + assert outcome == OUTCOME_FAILED + + def test_a_started_wake_is_fired(self): + (outcome, _detail), _ = _fire_with(SPAWNED, True) + assert outcome == OUTCOME_FIRED + + def test_an_exception_is_a_failure_not_a_block(self): + with ( + patch("aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch", side_effect=RuntimeError("boom")), + patch(f"{RUN}._should_notify", return_value=False), + ): + outcome, detail = _fire_job(_job(), {"jobs": {}}) + assert outcome == OUTCOME_FAILED + assert "boom" in detail + + def test_an_ok_lock_step_is_not_read_as_blocked(self): + """The success path records a 'lock' step too — status, not label, decides. + + The real shape: the wake got PAST the lock and occupancy gates, then + died at the spawn. Both cleared gates are still in the steps list, so a + reader that matched on label alone would call a genuine spawn failure a + block and retry it every window forever without ever recording it. + """ + status = FakeStatus( + [ + ("ok", "lock", "No active lock — agent is sleeping"), + ("ok", "occupancy", "No interactive session"), + ("fail", "spawn", "claude binary not found"), + ] + ) + (outcome, detail), _ = _fire_with(status, False) + assert outcome == OUTCOME_FAILED + assert "claude binary not found" in detail + + def test_a_cleared_gate_on_a_succeeding_wake_is_still_fired(self): + status = FakeStatus([("ok", "lock", "No active lock — agent is sleeping"), ("ok", "spawn", "started")]) + (outcome, _detail), _ = _fire_with(status, True) + assert outcome == OUTCOME_FIRED + + +class TestBlockedNeverStampsLastRun: + """The reported defect: a refusal that stamps last_run eats tomorrow.""" + + def test_record_job_blocked_leaves_last_run_untouched(self): + st = { + "jobs": { + "@vera/release-watch": {"last_run": "2026-08-29T10:00:11", "last_success_at": "2026-08-29T10:00:11"} + } + } + rs.record_job_blocked(st, "@vera", "release-watch", "blocked: interactive session running") + entry = st["jobs"]["@vera/release-watch"] + assert entry["last_run"] == "2026-08-29T10:00:11" + assert entry["last_success_at"] == "2026-08-29T10:00:11" + + def test_record_job_blocked_never_invents_a_last_run(self): + st = {} + rs.record_job_blocked(st, "@vera", "release-watch", "lock: active agent") + assert "last_run" not in st["jobs"]["@vera/release-watch"] + + def test_blocked_is_named_in_the_record(self): + st = {} + rs.record_job_blocked( + st, "@vera", "release-watch", "blocked: interactive session running", timestamp="2026-08-30T10:01:00" + ) + entry = st["jobs"]["@vera/release-watch"] + assert entry["last_status"] == "blocked" + assert entry["last_blocked_at"] == "2026-08-30T10:01:00" + assert "interactive session" in entry["last_error"] + + def test_blocked_does_not_arm_the_failure_backoff(self): + """last_failure_at is the failure path's field. Blocked is a different fact.""" + st = {} + rs.record_job_blocked(st, "@vera", "release-watch", "lock: active agent") + assert "last_failure_at" not in st["jobs"]["@vera/release-watch"] + + def test_the_error_text_is_capped_like_the_failure_path(self): + st = {} + rs.record_job_blocked(st, "@vera", "release-watch", "x" * 1000) + assert len(st["jobs"]["@vera/release-watch"]["last_error"]) == 500 + + +class TestBlockedStaysDueAndRetries: + """Half 2's whole point: the next tick inside the window fires.""" + + def _blocked_at(self, ts, schedule=DAILY, prior=None): + st = {"jobs": {"@vera/release-watch": dict(prior or {})}} + rs.record_job_blocked(st, "@vera", "release-watch", "blocked: interactive session", timestamp=ts) + return st + + def test_retry_fires_within_the_same_window(self): + st = self._blocked_at("2026-08-30T09:52:00") + assert rs.is_job_due(_job(), st, now=datetime(2026, 8, 30, 10, 4)) is True + + def test_yesterdays_success_does_not_suppress_todays_retry(self): + st = self._blocked_at( + "2026-08-30T09:52:00", prior={"last_run": "2026-08-29T10:00:11", "last_success_at": "2026-08-29T10:00:11"} + ) + assert rs.is_job_due(_job(), st, now=datetime(2026, 8, 30, 10, 4)) is True + + def test_the_retry_hold_is_a_bound_not_a_suppression(self): + """A blocked fire holds briefly, then retries — inside the same window.""" + st = self._blocked_at("2026-08-30T09:52:00") + held = datetime(2026, 8, 30, 9, 54) # 2 min later — next tick + assert held - datetime(2026, 8, 30, 9, 52) < timedelta(minutes=rs._BLOCKED_RETRY_MINUTES) + assert rs.is_job_due(_job(), st, now=held) is False + assert rs.is_job_due(_job(), st, now=datetime(2026, 8, 30, 9, 58)) is True + + def test_an_interval_job_blocked_forever_is_rate_limited(self): + """The storm this bound exists to prevent — interval measures from ATTEMPT.""" + st = self._blocked_at("2026-08-30T09:52:00", prior={"last_run": "2026-08-30T08:00:00"}) + job = _job(schedule=INTERVAL) + assert rs.is_job_due(job, st, now=datetime(2026, 8, 30, 9, 54)) is False + assert rs.is_job_due(job, st, now=datetime(2026, 8, 30, 9, 58)) is True + + def test_a_later_success_clears_the_hold(self): + """Measured on an INTERVAL job, so the hold is the only thing that could + + say no. A daily job would read False after a success for its own reason + (already ran today), which cannot tell a cleared hold from a live one. + """ + job = _job(schedule={"type": "interval", "interval_minutes": 1}) + st = self._blocked_at("2026-08-30T09:52:00") + rs.update_job_runstate(st, "@vera", "release-watch", job["schedule"], timestamp="2026-08-30T09:53:00") + # 09:55 is still inside the 5-minute hold started at 09:52. + assert rs.is_job_due(job, st, now=datetime(2026, 8, 30, 9, 55)) is True + + def test_a_failure_after_a_block_still_gets_the_longer_backoff(self): + st = self._blocked_at("2026-08-30T09:52:00") + rs.record_job_failure(st, "@vera", "release-watch", "spawn failed", timestamp="2026-08-30T09:58:00") + assert rs.is_job_due(_job(), st, now=datetime(2026, 8, 30, 10, 4)) is False + assert rs.is_job_due(_job(), st, now=datetime(2026, 8, 30, 10, 9)) is True + + +class TestBlockedIsNotAFailureStatus: + """The read the block must not disturb: what period is this measured from?""" + + def test_a_blocked_entry_still_measures_from_its_last_real_run(self): + """Listing "blocked" in _FAILURE_STATUSES would make _due_from() answer + + None — "never ran" — for a job that ran perfectly well before it was + refused once. A block says nothing about whether the period's work was + done; it says the wake could not start. + """ + st = {"last_run": "2026-08-29T10:00:11", "last_status": "blocked", "last_blocked_at": "2026-08-30T09:52:00"} + assert rs._due_from(st) == "2026-08-29T10:00:11" + + def test_a_failed_entry_still_measures_from_nothing(self): + st = {"last_run": "2026-08-29T10:00:11", "last_status": "failed", "last_failure_at": "2026-08-29T10:00:11"} + assert rs._due_from(st) is None + + +class TestTickAccounting: + """A blocked job is reported as blocked — never as fired, never as failed.""" + + def _tick(self, outcome, detail=""): + runstate = {"jobs": {}} + with ( + patch(f"{RUN}.discover_jobs", return_value=[_job(schedule=INTERVAL)]), + patch(f"{RUN}.load_runstate", return_value=runstate), + patch(f"{RUN}.save_runstate", return_value=True), + patch(f"{RUN}._fire_job", return_value=(outcome, detail)), + ): + return run_tick(), runstate + + def test_blocked_counted_separately(self): + results, _ = self._tick(OUTCOME_BLOCKED, "blocked: interactive session") + assert results["blocked"] == 1 + assert results["fired"] == 0 + assert results["failed"] == 0 + + def test_blocked_writes_the_blocked_record(self): + _results, runstate = self._tick(OUTCOME_BLOCKED, "blocked: interactive session") + entry = runstate["jobs"]["@vera/release-watch"] + assert entry["last_status"] == "blocked" + assert "last_run" not in entry + + def test_fired_still_stamps_a_run(self): + _results, runstate = self._tick(OUTCOME_FIRED) + assert runstate["jobs"]["@vera/release-watch"]["last_status"] == "success" + + def test_failed_still_stamps_a_run(self): + _results, runstate = self._tick(OUTCOME_FAILED, "spawn failed") + entry = runstate["jobs"]["@vera/release-watch"] + assert entry["last_status"] == "failed" + assert entry["last_run"] + + @pytest.mark.parametrize( + "outcome,expected_rc", + [(OUTCOME_BLOCKED, 0), (OUTCOME_FIRED, 0), (OUTCOME_FAILED, 1)], + ids=["blocked", "fired", "failed"], + ) + def test_only_a_real_failure_makes_the_tick_exit_red(self, outcome, expected_rc, tmp_path): + """A deferral is not a tick failure — the systemd unit must not read red. + + LOCK_FILE is seamed to tmp_path: the live daemon_json/schedule.lock is + the one the systemd timer takes every two minutes, and a test grabbing + it would either skip its own tick or make the real one skip. + """ + with ( + patch(f"{RUN}.LOCK_FILE", tmp_path / "schedule.lock"), + patch(f"{RUN}.discover_jobs", return_value=[_job(schedule=INTERVAL)]), + patch(f"{RUN}.load_runstate", return_value={"jobs": {}}), + patch(f"{RUN}.save_runstate", return_value=True), + patch(f"{RUN}._fire_job", return_value=(outcome, "detail")), + ): + assert run_mod._run_with_lock(dry_run=False) == expected_rc + + +class TestRotationIsUnchanged: + """A rotation miss already advances the pointer — it must keep consuming the night.""" + + def test_rotation_miss_is_not_reclassified_as_blocked(self): + job = _job(schedule={"type": "rotation", "time": "05:00"}, owner="@daemon", job_id="fleet-steward") + with patch(f"{RUN}.fire_rotation", return_value=(True, "missed @backup: lock")) as mock_rotation: + outcome, detail = _fire_job(job, {"jobs": {}}) + assert outcome == OUTCOME_FIRED + assert detail == "missed @backup: lock" + mock_rotation.assert_called_once() + + def test_rotation_failure_is_a_failure(self): + job = _job(schedule={"type": "rotation", "time": "05:00"}, owner="@daemon", job_id="fleet-steward") + with patch(f"{RUN}.fire_rotation", return_value=(False, "rotation roster is empty")): + outcome, _detail = _fire_job(job, {"jobs": {}}) + assert outcome == OUTCOME_FAILED diff --git a/src/aipass/daemon/tests/test_run_module.py b/src/aipass/daemon/tests/test_run_module.py index 2c2211350..a256b7991 100644 --- a/src/aipass/daemon/tests/test_run_module.py +++ b/src/aipass/daemon/tests/test_run_module.py @@ -13,6 +13,8 @@ from aipass.daemon.apps.modules.run import ( run_tick, handle_command, + OUTCOME_FAILED, + OUTCOME_FIRED, _fire_job, HANDLED_COMMANDS, ) @@ -72,7 +74,7 @@ def test_disabled_jobs_skipped(self, mock_rs, mock_discover): assert results["due"] == 0 @patch("aipass.daemon.apps.modules.run.save_runstate") - @patch("aipass.daemon.apps.modules.run._fire_job", return_value=(True, "")) + @patch("aipass.daemon.apps.modules.run._fire_job", return_value=(OUTCOME_FIRED, "")) @patch("aipass.daemon.apps.modules.run.discover_jobs") @patch("aipass.daemon.apps.modules.run.load_runstate", return_value={"jobs": {}}) def test_fires_due_job(self, mock_rs, mock_discover, mock_fire, mock_save): @@ -93,7 +95,7 @@ def test_fires_due_job(self, mock_rs, mock_discover, mock_fire, mock_save): mock_save.assert_called() @patch("aipass.daemon.apps.modules.run.save_runstate") - @patch("aipass.daemon.apps.modules.run._fire_job", return_value=(False, "wake failed")) + @patch("aipass.daemon.apps.modules.run._fire_job", return_value=(OUTCOME_FAILED, "wake failed")) @patch("aipass.daemon.apps.modules.run.discover_jobs") @patch("aipass.daemon.apps.modules.run.load_runstate", return_value={"jobs": {}}) def test_failed_fire_counted(self, mock_rs, mock_discover, mock_fire, mock_save): @@ -184,9 +186,11 @@ def test_rotation_job_goes_to_the_rotation_module(self): "prompt": "STEWARD NIGHT for {branch}.", } runstate = {"jobs": {}} + # fire_rotation keeps its own (ok, detail) answer; _fire_job maps it onto + # the three-state outcome the tick loop now reads. with patch(f"{RUN}.fire_rotation", return_value=(True, "woke @backup")) as mock_rotation: - ok, detail = _fire_job(job, runstate) - assert ok is True + outcome, detail = _fire_job(job, runstate) + assert outcome == OUTCOME_FIRED assert detail == "woke @backup" mock_rotation.assert_called_once_with(job, runstate) @@ -195,6 +199,6 @@ def test_ordinary_job_never_touches_the_rotation(self): patch(f"{RUN}.fire_rotation") as mock_rotation, patch("aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch", side_effect=RuntimeError("no wake")), ): - ok, _detail = _fire_job(live_job(), {"jobs": {}}) - assert ok is False + outcome, _detail = _fire_job(live_job(), {"jobs": {}}) + assert outcome == OUTCOME_FAILED mock_rotation.assert_not_called() diff --git a/src/aipass/daemon/tests/test_runstate_failure_retry.py b/src/aipass/daemon/tests/test_runstate_failure_retry.py new file mode 100644 index 000000000..c82a8e127 --- /dev/null +++ b/src/aipass/daemon/tests/test_runstate_failure_retry.py @@ -0,0 +1,210 @@ +# =================== AIPass ==================== +# Name: test_runstate_failure_retry.py +# Description: A failed fire must not consume its own period +# Version: 1.0.0 +# Created: 2026-08-30 +# Modified: 2026-08-30 +# ============================================= + +""" +A FAILED fire used to suppress its own retry for the rest of the period. + +Reported by @ai_mail (mail 826e02cd) after @devpulse spotted it, and measured +against this branch's live runstate before I touched anything: +record_job_failure() stamped ``last_run`` exactly as the success path does, and +every due-ness checker read ``last_run`` without ever consulting +``last_status``. So the field meaning "when did this last SUCCEED" was being +written by the failure path, and due-ness could not tell the two apart. + +Live proof at the time of the report - @vera/release-watch, whose only fire +that day raised "resolve: Branch not found", was not due again until the next +day. + +Two rules come out of the fix and both are pinned here: + + 1. WINDOWED schedules (daily, rotation, hourly) measure period-completion + from the last SUCCESS. A failure no longer counts as the period's work. + 2. A failure still buys a short BACKOFF, so a permanently-broken job retries + a couple of times inside its window instead of once per ~2-minute tick. + Interval jobs keep measuring from the last ATTEMPT - their interval is + already the bound, and measuring them from the last success would make a + never-succeeding job due on every tick forever. That is the storm this + fix must not create while removing the suppression. +""" + +from datetime import datetime, timedelta + +import pytest + +from aipass.daemon.apps.handlers.schedule import runstate as rs + + +DAILY = {"type": "daily", "time": "19:00"} +HOURLY = {"type": "hourly", "time": "2"} +INTERVAL = {"type": "interval", "interval_minutes": 60} + + +def _job(schedule, owner="@vera", job_id="release-watch"): + return {"owner": owner, "id": job_id, "enabled": True, "schedule": schedule} + + +def _state_after_failure(ts): + st = {} + rs.record_job_failure(st, "@vera", "release-watch", "resolve: Branch not found", timestamp=ts) + return st + + +def _state_after_success(ts, schedule=DAILY): + st = {} + rs.update_job_runstate(st, "@vera", "release-watch", schedule, timestamp=ts) + return st + + +class TestAFailedFireDoesNotConsumeItsPeriod: + """Rule 1. The reported defect, held for every windowed schedule type.""" + + @pytest.mark.parametrize( + "schedule,failed_at,retry_at", + [ + (DAILY, "2026-08-30T19:02:04", datetime(2026, 8, 30, 19, 14)), + ({"type": "rotation", "time": "19:00"}, "2026-08-30T19:02:04", datetime(2026, 8, 30, 19, 14)), + (HOURLY, "2026-08-30T19:02:04", datetime(2026, 8, 30, 19, 14)), + ], + ) + def test_a_windowed_job_is_due_again_after_a_failure(self, schedule, failed_at, retry_at): + st = _state_after_failure(failed_at) + + assert rs.is_job_due(_job(schedule), st, now=retry_at) is True + + def test_the_exact_live_entry_from_the_report_is_due_again(self): + # @vera/release-watch as it actually sat in daemon_runstate.json. + st = { + "jobs": { + rs.job_key("@vera", "release-watch"): { + "last_run": "2026-08-30T19:02:04.616354", + "last_status": "failed", + "last_failure_at": "2026-08-30T19:02:04.616354", + "last_error": "resolve: Branch not found: @vera", + } + } + } + + assert ( + rs._is_daily_due( + DAILY, rs._due_from(st["jobs"][rs.job_key("@vera", "release-watch")]), datetime(2026, 8, 30, 19, 14) + ) + is True + ) + + +class TestASuccessStillConsumesItsPeriod: + """The behaviour that must NOT change - success is still once per period.""" + + def test_a_succeeded_daily_job_is_not_due_again_the_same_day(self): + st = _state_after_success("2026-08-30T19:02:04") + + assert rs.is_job_due(_job(DAILY), st, now=datetime(2026, 8, 30, 19, 14)) is False + + def test_a_succeeded_daily_job_is_due_again_the_next_day(self): + st = _state_after_success("2026-08-29T19:02:04") + + assert ( + rs._is_daily_due( + DAILY, rs._due_from(st["jobs"][rs.job_key("@vera", "release-watch")]), datetime(2026, 8, 30, 19, 5) + ) + is True + ) + + def test_a_legacy_entry_with_no_last_success_at_still_counts_as_run(self): + # Entries written before last_success_at existed carry only last_run + # plus last_status. Treating those as "never succeeded" would re-fire + # every already-done job on this machine the moment the fix landed. + entry = {"last_run": "2026-08-30T19:02:04", "last_status": "success"} + + assert rs._due_from(entry) == "2026-08-30T19:02:04" + + def test_an_entry_with_no_status_at_all_is_read_as_a_run(self): + # Oldest shape: last_run alone. Same reasoning - absence of a failure + # marker is not evidence of a failure. + assert rs._due_from({"last_run": "2026-08-30T19:02:04"}) == "2026-08-30T19:02:04" + + def test_a_failed_entry_reports_no_usable_success(self): + assert rs._due_from({"last_run": "x", "last_status": "failed"}) is None + + +class TestTheFailureBackoffBoundsTheRetry: + """Rule 2. Removing the suppression must not create a per-tick storm.""" + + def test_a_job_that_just_failed_is_not_retried_on_the_next_tick(self): + # The tick cadence is ~2 minutes; without this the +/-15 min daily + # window would fire a permanently-broken job about 8 times a day. + st = _state_after_failure("2026-08-30T19:02:04") + two_minutes_later = datetime(2026, 8, 30, 19, 4) + + assert rs._in_failure_backoff(st["jobs"][rs.job_key("@vera", "release-watch")], two_minutes_later) is True + + def test_the_backoff_expires_and_the_retry_happens(self): + st = _state_after_failure("2026-08-30T19:02:04") + entry = st["jobs"][rs.job_key("@vera", "release-watch")] + + assert rs._in_failure_backoff(entry, datetime(2026, 8, 30, 19, 14)) is False + assert rs.is_job_due(_job(DAILY), st, now=datetime(2026, 8, 30, 19, 14)) is True + + def test_a_broken_daily_job_fires_a_bounded_number_of_times_in_its_window(self): + # The whole point, measured rather than asserted: walk the real tick + # cadence across the real window and count. + st = _state_after_failure("2026-08-30T18:45:00") + fires = 0 + now = datetime(2026, 8, 30, 18, 45) + end = datetime(2026, 8, 30, 19, 15) + while now <= end: + if rs.is_job_due(_job(DAILY), st, now=now): + fires += 1 + rs.record_job_failure(st, "@vera", "release-watch", "still broken", timestamp=now.isoformat()) + now += timedelta(minutes=2) + + assert 1 <= fires <= 4, f"a broken daily job fired {fires} times in one window" + + def test_a_successful_job_is_never_held_by_a_stale_backoff(self): + # An entry that failed once and later succeeded must not be blocked by + # the old last_failure_at still sitting in the record. + st = _state_after_failure("2026-08-30T19:02:04") + rs.update_job_runstate(st, "@vera", "release-watch", DAILY, timestamp="2026-08-30T19:03:00") + entry = st["jobs"][rs.job_key("@vera", "release-watch")] + + assert rs._in_failure_backoff(entry, datetime(2026, 8, 30, 19, 4)) is False + + +class TestIntervalJobsKeepMeasuringFromTheAttempt: + """The regression this fix must not introduce.""" + + def test_a_never_succeeding_interval_job_is_not_due_every_tick(self): + st = _state_after_failure("2026-08-30T19:02:04") + + assert rs.is_job_due(_job(INTERVAL), st, now=datetime(2026, 8, 30, 19, 30)) is False + + def test_an_interval_job_retries_once_its_interval_has_passed(self): + st = { + "jobs": { + rs.job_key("@vera", "release-watch"): { + "last_run": "2026-08-30T18:00:00", + "last_status": "failed", + "last_failure_at": "2026-08-30T18:00:00", + } + } + } + + assert rs._is_interval_due(INTERVAL, "2026-08-30T18:00:00", datetime(2026, 8, 30, 19, 5)) is True + assert rs.is_job_due(_job(INTERVAL), st, now=datetime(2026, 8, 30, 19, 5)) is True + + +class TestLastRunStillMeansTheLastAttempt: + """Nothing here quietly changes what the queue display reports.""" + + def test_a_failure_still_stamps_last_run_for_the_queue(self): + st = _state_after_failure("2026-08-30T19:02:04") + entry = st["jobs"][rs.job_key("@vera", "release-watch")] + + assert entry["last_run"] == "2026-08-30T19:02:04" + assert entry["last_status"] == "failed" + assert entry["last_error"].startswith("resolve:") diff --git a/src/aipass/daemon/tests/test_scheduler_bot.py b/src/aipass/daemon/tests/test_scheduler_bot.py index eaee31270..4f4384d05 100644 --- a/src/aipass/daemon/tests/test_scheduler_bot.py +++ b/src/aipass/daemon/tests/test_scheduler_bot.py @@ -304,7 +304,9 @@ def test_fire_continues_when_notify_fails(self, mock_rs, mock_discover, mock_upd } ] - with patch("aipass.daemon.apps.modules.run._fire_job", return_value=(True, "")): + from aipass.daemon.apps.modules.run import OUTCOME_FIRED + + with patch("aipass.daemon.apps.modules.run._fire_job", return_value=(OUTCOME_FIRED, "")): results = run_tick() assert results["fired"] == 1 diff --git a/src/aipass/daemon/tests/test_timer_install.py b/src/aipass/daemon/tests/test_timer_install.py index 3fe7cbc39..367f5928f 100644 --- a/src/aipass/daemon/tests/test_timer_install.py +++ b/src/aipass/daemon/tests/test_timer_install.py @@ -9,7 +9,10 @@ """Tests for the timer_install module (systemd user timer installer).""" import subprocess + +import pytest from unittest.mock import patch, MagicMock +import sys from pathlib import Path from aipass.daemon.apps.modules.timer_install import ( @@ -72,6 +75,24 @@ def test_timeout(self, mock_run): class TestInstall: + @pytest.fixture(autouse=True) + def _no_real_home(self, tmp_path_factory): + """Redirect the shared state dir for EVERY test in this class. + + Autouse rather than a per-test patch, deliberately. Adding the + _STATE_DIR seam fixed nothing on its own: the pre-existing + test_install_success still patched only _DAEMON_ROOT and _UNIT_DIR, so + the re-run artifact still showed the same single os.mkdir on the real + ~/.aipass. A seam a test can forget to use is not a fix — the next + _install() test would reach the real home again and the suite would + stay green while doing it. + """ + with patch( + "aipass.daemon.apps.modules.timer_install._STATE_DIR", + tmp_path_factory.mktemp("state"), + ): + yield + """Tests for the install flow.""" def test_install_missing_unit_file(self): @@ -148,3 +169,70 @@ def test_uninstall_removes_files(self, mock_systemctl, tmp_path): assert result == 0 assert not service.exists() assert not timer.exists() + + +class TestNoRealHomeWrites: + """The suite must not touch the user's real home. + + Found by `drone @seedgo audit tests @daemon` and confirmed by @seedgo as + the ONE hygiene record in daemon's artifact that genuinely left the copy — + everything else in that 1097 is real prax logging firing under an + unrebindable module-scope binding, which is not this branch's to fix. + + `~/.aipass` is not a scratch directory: it holds `admin_grant.key`, + `commons.db` with its -shm/-wal siblings, and `telegram_bots/`. The mkdir + itself was harmless (`exist_ok=True` against a directory that already + exists), so this is a seam defect rather than damage — but the next write + added after that line would land in real state on every machine that runs + the suite, and no test could have noticed. + """ + + def test_install_does_not_touch_the_real_home(self, tmp_path): + """Red-first: fails while the path is computed inline at call time. + + The two existing patches (_DAEMON_ROOT, _UNIT_DIR) reach module + constants. The .aipass mkdir was `Path.home().joinpath(...)` evaluated + inside _install(), so no patch could reach it and this assertion caught + a real os.mkdir on the real home. + """ + recorded = [] + + def watch(event, args): + if event == "os.mkdir" and args: + recorded.append(str(args[0])) + + service = tmp_path / "daemon-tick.service" + timer = tmp_path / "daemon-tick.timer" + service.write_text("[Unit]\n") + timer.write_text("[Unit]\n") + install_dir = tmp_path / "systemd" + install_dir.mkdir() + state_dir = tmp_path / "state" + + sys.addaudithook(watch) + with ( + patch("aipass.daemon.apps.modules.timer_install._DAEMON_ROOT", tmp_path), + patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", install_dir), + patch("aipass.daemon.apps.modules.timer_install._STATE_DIR", state_dir), + patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True), + patch("shutil.copy2"), + ): + assert _install() == 0 + + # Scoped to the user's HOME STATE, deliberately, and not to "anything + # outside tmp_path". A broader assertion also catches the prax/trigger/ + # daemon_json logging writes into the repo checkout — real, measured + # (66 in this one test), and NOT this branch's to fix: they come from + # `from aipass.prax import logger` binding the logger object at import, + # which no conftest can rebind. @seedgo owns that standard and has + # widened it; @prax has the contract question. Asserting on them here + # would make this pin fail for a reason it is not testing, and a test + # that goes red for someone else's open defect gets muted, not fixed. + repo_root = Path(__file__).resolve().parents[4] + home_state = [ + p + for p in recorded + if p.startswith(str(Path.home())) and not p.startswith(str(repo_root)) and not p.startswith(str(tmp_path)) + ] + assert home_state == [], f"suite wrote into the real home: {home_state}" + assert state_dir.is_dir(), "the state dir should still be created, just where it was told" diff --git a/src/aipass/devpulse/apps/handlers/__init__.py b/src/aipass/devpulse/apps/handlers/__init__.py index ed42bedda..1475694ae 100644 --- a/src/aipass/devpulse/apps/handlers/__init__.py +++ b/src/aipass/devpulse/apps/handlers/__init__.py @@ -1,25 +1,53 @@ """Devpulse handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "aipass.devpulse" def _find_real_caller(): - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) - - for frame_info in stack: - filename = frame_info.filename - if this_file in str(Path(filename).resolve()): - continue + """Walk the stack for the file that triggered this import. + + Walks frames with sys._getframe rather than inspect.stack(): inspect + builds a FrameInfo per frame, whose getmodule() calls os.path.realpath() + outside any try — and ntpath.realpath calls os.getcwd() unconditionally, + so on Windows the guard would need a readable cwd at import (measured on + the Windows CI gate 2026-08-31; on POSIX the same raise is swallowed + earlier, inside getabsfile). A frame's co_filename is already a string + in memory; reading it touches nothing. + """ + # Path.resolve() reaches the same ntpath.realpath — __file__ is already + # absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + # Internals first — resolve() on a pseudo-filename like needs a + # cwd, and a process whose cwd was deleted dies here otherwise. if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back + continue + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing every consumer's import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() - return str(Path(filename).resolve()), import_line + # linecache reads one named file and returns "" rather than raising. + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None + return resolved, import_line return None, None @@ -37,10 +65,10 @@ def _guard_branch_access(): caller_file, import_line = _find_real_caller() if caller_file is None: - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return + # No caller outside this file: an interactive session, a -c script, or + # an importlib-only stack — all allowed. The old second inspect.stack() + # walk here returned either way; a second copy of the cwd dependency in + # service of a branch that could not change the answer. return if "pytest" in caller_file or "/_pytest/" in caller_file: diff --git a/src/aipass/devpulse/apps/handlers/compass/store.py b/src/aipass/devpulse/apps/handlers/compass/store.py index b73b7d79d..8b0b81598 100644 --- a/src/aipass/devpulse/apps/handlers/compass/store.py +++ b/src/aipass/devpulse/apps/handlers/compass/store.py @@ -46,11 +46,12 @@ logger.info("[compass] prax logger unavailable, using stdlib logging: %s", _prax_exc) from aipass.devpulse.apps.handlers.json import json_handler +from aipass.devpulse.apps.handlers.module_root import module_file # Branch-root-relative default DB path. store.py lives at # /apps/handlers/compass/store.py, so parents[3] is the branch # root. NEVER hardcode an absolute /home/... path here. -_BRANCH_ROOT = Path(__file__).resolve().parents[3] +_BRANCH_ROOT = module_file(__file__).parents[3] DEFAULT_DB_PATH = _BRANCH_ROOT / "devpulse_json" / "compass" / "compass.db" VALID_RATINGS = ("good", "bad", "impressive", "interesting") diff --git a/src/aipass/devpulse/apps/handlers/feedback/compose.py b/src/aipass/devpulse/apps/handlers/feedback/compose.py index 491c9659f..b07dc459a 100644 --- a/src/aipass/devpulse/apps/handlers/feedback/compose.py +++ b/src/aipass/devpulse/apps/handlers/feedback/compose.py @@ -27,15 +27,16 @@ from aipass.cli.apps.modules import err_console, error, success, warning from aipass.devpulse.apps.handlers.json import json_handler +from aipass.devpulse.apps.handlers.module_root import module_file console = err_console # AIPass src/aipass/ directory (four levels up from compose.py) -_AIPASS_ROOT = Path(__file__).resolve().parents[4] +_AIPASS_ROOT = module_file(__file__).parents[4] # Devpulse's own ai_mail inbox — stamped as reply_path on delivered replies so # external recipients can reply cross-project via ai_mail's stored-path route. -_DEVPULSE_INBOX = Path(__file__).resolve().parents[3] / ".ai_mail.local" / "inbox.json" +_DEVPULSE_INBOX = module_file(__file__).parents[3] / ".ai_mail.local" / "inbox.json" def _resolve_sender() -> tuple[str, str]: diff --git a/src/aipass/devpulse/apps/handlers/feedback/storage.py b/src/aipass/devpulse/apps/handlers/feedback/storage.py index 35e7ae454..7d0e207bc 100644 --- a/src/aipass/devpulse/apps/handlers/feedback/storage.py +++ b/src/aipass/devpulse/apps/handlers/feedback/storage.py @@ -19,9 +19,10 @@ from aipass.prax import logger from aipass.devpulse.apps.handlers.json import json_handler +from aipass.devpulse.apps.handlers.module_root import module_file # devpulse/ root (three levels up from this file: handlers/feedback/storage.py -> apps/ -> devpulse/) -_DEVPULSE_ROOT = Path(__file__).resolve().parents[3] +_DEVPULSE_ROOT = module_file(__file__).parents[3] FEEDBACK_DIR = _DEVPULSE_ROOT / ".feedback.local" diff --git a/src/aipass/devpulse/apps/handlers/json/json_handler.py b/src/aipass/devpulse/apps/handlers/json/json_handler.py index 73b2b5c33..354a94eb1 100644 --- a/src/aipass/devpulse/apps/handlers/json/json_handler.py +++ b/src/aipass/devpulse/apps/handlers/json/json_handler.py @@ -24,8 +24,9 @@ from typing import Any from aipass.prax import logger +from aipass.devpulse.apps.handlers.module_root import module_file -_BRANCH_ROOT: Path = Path(__file__).resolve().parents[3] +_BRANCH_ROOT: Path = module_file(__file__).parents[3] _BRANCH_NAME: str = _BRANCH_ROOT.name JSON_DIR: Path = _BRANCH_ROOT / f"{_BRANCH_NAME}_json" diff --git a/src/aipass/devpulse/apps/handlers/module_root.py b/src/aipass/devpulse/apps/handlers/module_root.py new file mode 100644 index 000000000..f5ef05bc9 --- /dev/null +++ b/src/aipass/devpulse/apps/handlers/module_root.py @@ -0,0 +1,95 @@ +# =================== AIPass ==================== +# Name: module_root.py +# Description: Resolve a module's __file__ without an import-time cwd read +# Version: 1.1.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""One guarded spelling for module-level __file__ resolution. + +ntpath.realpath reads os.getcwd() UNCONDITIONALLY - not only for relative +paths, the way posixpath does - and Path.resolve() routes through it +(measured on the Windows CI gate 2026-08-31, @memory's finding). So on +Windows every module-level Path(__file__).resolve() is an import-time +working-directory dependency: a process whose cwd was deleted cannot import +the module. Devpulse carried six such sites; they route through +module_file() instead of each growing its own try/except. + +Shape mirrors @memory's repo_root.module_file - the ratified fleet cure - +sized to this branch: devpulse has no module-level repo-root walk, so only +the resolve guard lives here. +""" + +import sys +from pathlib import Path + +from aipass.prax.apps.modules.logger import get_system_logger + +logger = get_system_logger() + +MODULE_NAME = "module_root" + + +def module_file(file: str) -> Path: + """A module's own path, absolute, resolved when the filesystem allows. + + .resolve() is still attempted, because normalising symlinks is why the + call is there and it succeeds on every healthy machine. The fallback is + only reached in the world where the alternative is a dead import, and it + is sound: __file__ has been absolute since Python 3.9, so the return is + the right file either way - just spelled through the symlink rather than + past it. + + Args: + file: A module's __file__. + + Returns: + The module's path, resolved if the filesystem could be asked. + """ + path = Path(file) + try: + return path.resolve() + except OSError as exc: + # The diagnostics live inside their own protection (@daemon's finding + # on this very file, 2026-08-31): the world that reaches this line is + # exactly the world where the logger may be down too - prax's logger + # construction reads the cwd. A crashing diagnostic here would BECOME + # the import crash this module exists to prevent. + try: + _record_unresolved(path, exc) + except Exception as inner: # noqa: BLE001 - last resort below, never a re-raise + # stderr is the one channel left that asks nothing of the + # filesystem; not silent, just not the usual instrument. + sys.stderr.write( + f"[{MODULE_NAME}] Cannot resolve {path} and cannot record it ({type(inner).__name__}: {inner})\n" + ) + return path + + +def _record_unresolved(path: Path, exc: OSError) -> None: + """Record the fallback; module_file wraps this call in its own protection. + + Runs at module import time on all six callers. The logger line is the + primary diagnostic and may itself raise in the world that gets here - + that raise belongs to module_file's wrapper, which owns the promise + that no diagnostic becomes an import crash. + + Args: + path: The __file__ that could not be resolved. + exc: The OSError resolve() raised. + """ + # debug, not warning: in the world that reaches this line every module + # import takes it, and six identical warnings describe one condition - + # reported once, loudly, by whichever lane actually fails on it. + logger.debug(f"[{MODULE_NAME}] Cannot resolve {path} ({type(exc).__name__}) — using its absolute spelling") + try: + from aipass.devpulse.apps.handlers.json import json_handler + + json_handler.log_operation( + "module_file_unresolved", + {"path": str(path), "error": f"{type(exc).__name__}: {exc}"}, + module_name=MODULE_NAME, + ) + except Exception as inner: # noqa: BLE001 - an audit line must never take an import down + logger.debug(f"[{MODULE_NAME}] fallback not recorded: {type(inner).__name__}: {inner}") diff --git a/src/aipass/devpulse/apps/handlers/owner/admin_grant.py b/src/aipass/devpulse/apps/handlers/owner/admin_grant.py index 4118dbb06..8aaceb57d 100644 --- a/src/aipass/devpulse/apps/handlers/owner/admin_grant.py +++ b/src/aipass/devpulse/apps/handlers/owner/admin_grant.py @@ -49,9 +49,10 @@ from aipass.prax import logger from aipass.devpulse.apps.handlers.json import json_handler +from aipass.devpulse.apps.handlers.module_root import module_file # Branch root: .../src/aipass/devpulse (this file sits at apps/handlers/owner/) -_BRANCH_ROOT = Path(__file__).resolve().parents[3] +_BRANCH_ROOT = module_file(__file__).parents[3] DEFAULT_KEY_PATH = Path.home() / ".aipass" / "admin_grant.key" DEFAULT_CERT_PATH = _BRANCH_ROOT / "artifacts" / "birth_certificate.json" diff --git a/src/aipass/devpulse/apps/handlers/watchdog/agent.py b/src/aipass/devpulse/apps/handlers/watchdog/agent.py index d78feebcf..2baf98993 100644 --- a/src/aipass/devpulse/apps/handlers/watchdog/agent.py +++ b/src/aipass/devpulse/apps/handlers/watchdog/agent.py @@ -114,7 +114,10 @@ def _resolve_branch_path(agent_id: str) -> Path | None: # main registry so a local branch always wins a name collision. # Live miss 2026-08-12: watchdog agent @baud said agent-not-found # minutes after the admin lane's first dispatch reached that seat. - for reg in sorted((repo_root / "projects").glob("*/*_REGISTRY.json")): + # glob is case-insensitive on Windows — re-check the suffix exactly so + # a *_registry.json counter is never read as a trust anchor + project_regs = (repo_root / "projects").glob("*/*_REGISTRY.json") + for reg in sorted(p for p in project_regs if p.name.endswith("_REGISTRY.json")): result = _search_registry(reg, target) if result is not None: return result @@ -134,6 +137,8 @@ def _resolve_branch_path(agent_id: str) -> Path | None: if not root.is_dir(): continue for reg in root.glob("*_REGISTRY.json"): + if not reg.name.endswith("_REGISTRY.json"): + continue # Windows globs case-insensitively; exact-case names only resolved = reg.resolve() if resolved in seen: continue diff --git a/src/aipass/devpulse/apps/handlers/watchdog/registry.py b/src/aipass/devpulse/apps/handlers/watchdog/registry.py index c8bd74c4c..415edd992 100644 --- a/src/aipass/devpulse/apps/handlers/watchdog/registry.py +++ b/src/aipass/devpulse/apps/handlers/watchdog/registry.py @@ -62,27 +62,23 @@ _KILL_POLL_INTERVAL = 0.1 -def _find_devpulse_root(start: Path | None = None) -> Path | None: - """Walk upward looking for AIPASS_REGISTRY.json, then return the devpulse dir.""" - cur = (start or Path.cwd()).resolve() - for candidate in [cur, *cur.parents]: - if (candidate / "AIPASS_REGISTRY.json").exists(): - devpulse_dir = candidate / "src" / "aipass" / "devpulse" - if devpulse_dir.exists(): - return devpulse_dir - return candidate - for candidate in [cur, *cur.parents]: - if candidate.name == "devpulse": - return candidate - return None +def _devpulse_root() -> Path: + """The devpulse package dir, derived from ``__file__`` — never from cwd. + + The store is package state, so it lives where the package lives. The old + walk keyed on AIPASS_REGISTRY.json — machine-local runtime state, absent + on every fresh checkout — and fell back to Path.cwd(), which planted + ``.watchdog/`` at whatever directory the process happened to stand in + (CI's composed runner hands children cwd = repo root; caught by seedgo's + left-nothing-behind fixture, 2026-08-31). ``__file__`` gives the same + answer on a dev machine, a fresh checkout, and a dead cwd. + """ + return Path(__file__).resolve().parents[3] def _default_storage_path() -> Path: """Resolve `.watchdog/watchdog_active.json` relative to the devpulse root.""" - root = _find_devpulse_root() - if root is None: - root = Path.cwd() - return root / ".watchdog" / _STORAGE_FILENAME + return _devpulse_root() / ".watchdog" / _STORAGE_FILENAME def _empty_store() -> dict: diff --git a/src/aipass/devpulse/apps/handlers/watchdog/timer.py b/src/aipass/devpulse/apps/handlers/watchdog/timer.py index c05799562..37e7e1e5b 100644 --- a/src/aipass/devpulse/apps/handlers/watchdog/timer.py +++ b/src/aipass/devpulse/apps/handlers/watchdog/timer.py @@ -53,29 +53,22 @@ def _stderr(msg: str) -> None: sys.stderr.flush() -def _find_devpulse_root(start: Path | None = None) -> Path | None: - """Walk upward looking for AIPASS_REGISTRY.json, then return the devpulse dir.""" - cur = (start or Path.cwd()).resolve() - for candidate in [cur, *cur.parents]: - if (candidate / "AIPASS_REGISTRY.json").exists(): - devpulse_dir = candidate / "src" / "aipass" / "devpulse" - if devpulse_dir.exists(): - return devpulse_dir - return candidate - for candidate in [cur, *cur.parents]: - if candidate.name == "devpulse": - return candidate - return None +def _devpulse_root() -> Path: + """The devpulse package dir, derived from ``__file__`` — never from cwd. + + The store is package state, so it lives where the package lives. The old + walk keyed on AIPASS_REGISTRY.json (machine-local, absent on fresh + checkouts) and fell back to Path.cwd() — the comment defending that branch + as production-only was refuted by CI on 2026-08-31: agent.watch calls + register() with no storage_path, so the default IS reachable from tests, + and a child with cwd = repo root planted ``.watchdog/`` in the tree. + """ + return Path(__file__).resolve().parents[3] def _default_storage_path() -> Path: """Resolve `.watchdog/watchdog_timers.json` relative to the devpulse root.""" - root = _find_devpulse_root() - if root is None: - # Fall back to cwd so callers still get a deterministic path — tests - # always pass an explicit storage_path so this branch is production-only. - root = Path.cwd() - return root / ".watchdog" / _STORAGE_FILENAME + return _devpulse_root() / ".watchdog" / _STORAGE_FILENAME def _empty_store() -> dict: diff --git a/src/aipass/devpulse/tests/conftest.py b/src/aipass/devpulse/tests/conftest.py index a80f622d3..fef892cda 100644 --- a/src/aipass/devpulse/tests/conftest.py +++ b/src/aipass/devpulse/tests/conftest.py @@ -17,6 +17,13 @@ from typing import Generator +def pytest_configure(config: pytest.Config) -> None: + # Registered here, not pytest.ini: the composed run (rootdir=repo, + # -c pyproject.toml) never reads this branch's ini, and its + # --strict-markers would turn an unknown marker into an error. + config.addinivalue_line("markers", "integration: live-dispatch integration tests (WATCHDOG_INTEGRATION=1)") + + @pytest.fixture def temp_test_dir() -> Generator[Path, None, None]: """Creates temporary directory for testing, cleans up after""" diff --git a/src/aipass/devpulse/tests/test_import_dead_cwd.py b/src/aipass/devpulse/tests/test_import_dead_cwd.py new file mode 100644 index 000000000..6bc6e12f4 --- /dev/null +++ b/src/aipass/devpulse/tests/test_import_dead_cwd.py @@ -0,0 +1,242 @@ +# =================== AIPass ==================== +# Name: test_import_dead_cwd.py +# Description: Pins devpulse imports against a dead working directory +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Every devpulse module must import without a readable working directory. + +The mechanism, measured on the Windows CI gate 2026-08-31 (@memory's finding): +ntpath.realpath calls os.getcwd() UNCONDITIONALLY - not only for relative +paths, the way posixpath does - and Path.resolve() routes through it. So on +Windows every module-level Path(__file__).resolve() is an import-time +working-directory read, and a process whose cwd was deleted cannot import the +module at all. devpulse carried six such sites. + +The world injects ntpath's behaviour as a CONDITION rather than a platform: +os.path.realpath is wrapped to read os.getcwd() first, then os.getcwd is +denied. The injection happens in a child process before any aipass import, so +no module has cached the real functions. + +Where the interpreter's own pathlib never routes resolve() through +os.path.realpath (3.10 resolves absolute paths without touching cwd), the +denial cannot fire and the probe says so - the pin still asserts the imports +succeed there, it just proves less. Pinned as a probe with both outcomes, +never a skipif: the vacuous world is named in the output, and the test +asserts vacuity only occurs on interpreters where it is the truth. +""" + +import subprocess +import sys + +WORLD = r""" +# Other branches' import-time code is held CONSTANT: preloaded in the healthy +# world, before the denial. Their dead-cwd cure is their own build (fleet +# rollout in flight, 2026-08-31); this pin measures devpulse's sites only. +# When the fleet is cured these preloads can drop. +import aipass.prax # noqa: F401 +import aipass.prax.apps.modules.logger # noqa: F401 +import aipass.cli.apps.modules # noqa: F401 + +import os + +_real_realpath = os.path.realpath + + +def _ntpath_condition(path, **kw): + os.getcwd() # ntpath.realpath reads cwd before checking absoluteness + return _real_realpath(path, **kw) + + +os.path.realpath = _ntpath_condition + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd + +# Probe the instrument: does THIS interpreter's resolve() reach the denied +# call for an absolute path? 3.11+ routes through os.path.realpath; 3.10 +# resolves absolute paths without cwd, so the denial cannot fire there. +import pathlib + +try: + pathlib.Path(pathlib.__file__).resolve() + print("PROBE_VACUOUS") +except FileNotFoundError: + print("PROBE_ARMED") + +import aipass.devpulse.apps.handlers.owner.admin_grant # noqa: F401 +import aipass.devpulse.apps.handlers.feedback.storage # noqa: F401 +import aipass.devpulse.apps.handlers.feedback.compose # noqa: F401 +import aipass.devpulse.apps.handlers.json.json_handler # noqa: F401 +import aipass.devpulse.apps.handlers.compass.store # noqa: F401 + +print("IMPORTED") +""" + + +def _inspect_stack_calls(source: str) -> list[int]: + """Line numbers of every inspect.stack() call in *source*, by parse. + + An AST matcher, never a string scan: the guard's own docstring names + inspect.stack() while explaining the defect, and a spelling ban would + convict the explanation while acquitting the code. + """ + import ast + + tree = ast.parse(source) + return [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "stack" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "inspect" + ] + + +def test_the_guard_never_calls_inspect_stack(): + """Structural pin on the guard cure - import probes cannot carry this. + + The deleted second inspect.stack() walk sat in the caller-is-None branch, + which no import-shaped world ever reaches: apps/__init__.py always + supplies a real-file frame, so _find_real_caller never returns None + during an import. @trigger measured the hole - restoring the defect left + their whole suite green. Only a parse of the file convicts it. + """ + from pathlib import Path + + guard = (Path(__file__).resolve().parents[1] / "apps" / "handlers" / "__init__.py").read_text(encoding="utf-8") + + assert _inspect_stack_calls(guard) == [], ( + "inspect.stack() is back in the guard - it needs a readable cwd on " + "Windows before any of the guard's own code runs (getmodule's " + "os.path.realpath sits outside any try)" + ) + assert "import inspect" not in [line.strip() for line in guard.splitlines()], ( + "the guard imports inspect again - nothing in the cured shape needs it" + ) + + # Positive control: the matcher must convict a real call... + assert _inspect_stack_calls("import inspect\nframes = inspect.stack()\n") == [2] + # ...and clear prose that names the defect (docstring negative control). + assert _inspect_stack_calls('"""inspect.stack() is banned here."""\n') == [] + # ...and clear an unrelated .stack attribute (numpy.stack shape). + assert _inspect_stack_calls("import numpy\nnumpy.stack([])\n") == [] + + +def test_fallback_diagnostics_never_take_the_lane_down(monkeypatch, capsys): + """@daemon's flag on this branch (2026-08-31): module_file's fallback + logged OUTSIDE its own protection. The world that reaches the fallback + is exactly the world where the logger may be down too - prax's logger + construction reads the cwd - so an unguarded diagnostic becomes the + import crash the helper exists to prevent. Mutant-verified: restoring + the unprotected logger.debug in module_file's except turns this red. + """ + from pathlib import Path + + from aipass.devpulse.apps.handlers import module_root + + def _logger_is_down(*args, **kwargs): + raise OSError("logger lane is down with the cwd") + + monkeypatch.setattr(module_root.logger, "debug", _logger_is_down) + + def _resolve_denied(self, strict=False): + raise OSError("resolve denied") + + monkeypatch.setattr(module_root.Path, "resolve", _resolve_denied) + + result = module_root.module_file(__file__) + + # The right file still comes back, in its unresolved spelling... + assert result == Path(__file__) + # ...and the double failure reached the last-resort channel rather than + # vanishing - the wrapper reports, it never swallows. + assert "cannot record it" in capsys.readouterr().err + + +DIRECT_CALL_WORLD = r""" +# @spawn's finding (2026-08-31): the caller-is-None branch is unreachable from +# IMPORT-shaped pins (apps/__init__ always supplies a real-file frame) but IS +# reachable by calling the guard directly from a -c child - every frame is +# or importlib, both skipped, so _find_real_caller returns (None, +# None) and the branch runs. A regrown inspect.stack() walk there dies under +# the realpath denial; the cured plain return survives it. +import aipass.prax # noqa: F401 +import aipass.prax.apps.modules.logger # noqa: F401 +import aipass.cli.apps.modules # noqa: F401 +import aipass.devpulse.apps.handlers as handlers + +import os + + +def _denied_realpath(path, **kw): + raise FileNotFoundError(2, "realpath denied", str(path)) + + +os.path.realpath = _denied_realpath + +# Arming probe 1: the denial must bite before its silence means anything. +try: + os.path.realpath("x") + print("DENIAL_INERT") +except FileNotFoundError: + print("DENIAL_ARMED") + +# Arming probe 2: this world must actually reach the None branch. +caller = handlers._find_real_caller() +print(f"CALLER_IS_NONE:{caller == (None, None)}") + +handlers._guard_branch_access() +print("GUARD_RETURNED") +""" + + +def test_the_none_branch_is_reachable_by_direct_call(): + """Behavioural sibling of the AST ban - @spawn measured the shape. + + The AST pin names the defect precisely but is a parse, not a run; this + world RUNS the caller-is-None branch under a realpath denial. Both + instruments watch the same branch from different sides: restoring the + deleted inspect.stack() walk turns this red (the walk executes under the + denial) and the AST pin red (the call is back in the source). + """ + result = subprocess.run( + [sys.executable, "-c", DIRECT_CALL_WORLD], + capture_output=True, + text=True, + timeout=60, + ) + out = result.stdout + assert "DENIAL_ARMED" in out, f"the realpath denial never bit - vacuous world:\n{out}\n{result.stderr}" + assert "CALLER_IS_NONE:True" in out, f"the world never reached the None branch - wrong path exercised:\n{out}" + assert "GUARD_RETURNED" in out, ( + f"the guard died in the caller-is-None branch under a realpath denial:\n{out}\n{result.stderr}" + ) + + +def test_handlers_import_with_dead_cwd(): + result = subprocess.run( + [sys.executable, "-c", WORLD], + capture_output=True, + text=True, + timeout=60, + ) + out = result.stdout + assert "IMPORTED" in out, f"import died under the dead-cwd world:\nstdout={out}\nstderr={result.stderr}" + if "PROBE_VACUOUS" in out: + # Allowed only where it is the interpreter's truth (pre-3.11 pathlib + # never routes an absolute resolve through os.path.realpath). + assert sys.version_info < (3, 11), ( + "resolve() survived the denial on an interpreter that routes " + "through os.path.realpath - the instrument is broken, not the world" + ) + else: + assert "PROBE_ARMED" in out diff --git a/src/aipass/devpulse/tests/test_json_handler_template.py b/src/aipass/devpulse/tests/test_json_handler_template.py index de3b7b0f7..10da092b6 100644 --- a/src/aipass/devpulse/tests/test_json_handler_template.py +++ b/src/aipass/devpulse/tests/test_json_handler_template.py @@ -36,7 +36,7 @@ # ============ BRANCH CONFIG ============ # Change these two lines when deploying to a branch: -BRANCH_MODULE = "seedgo" # e.g. "prax", "drone", "backup", "cli", etc. +BRANCH_MODULE = "devpulse" # e.g. "prax", "drone", "backup", "cli", etc. # For commons: "commons" (import path is different: aipass -> just commons) # For skills: "skills" (import path is different: aipass -> just skills) # ======================================= diff --git a/src/aipass/devpulse/tests/test_watchdog_agent.py b/src/aipass/devpulse/tests/test_watchdog_agent.py index b36c0eb7b..7d2184a5c 100644 --- a/src/aipass/devpulse/tests/test_watchdog_agent.py +++ b/src/aipass/devpulse/tests/test_watchdog_agent.py @@ -28,6 +28,24 @@ import pytest from aipass.devpulse.apps.handlers.watchdog import agent as agent_handler +from aipass.devpulse.apps.handlers.watchdog import registry as watch_registry + + +@pytest.fixture(autouse=True) +def _hermetic_watch_store(tmp_path, monkeypatch): + """Every watch_agent call registers a handle via the DEFAULT store path. + + Unpatched, these tests wrote the live .watchdog/watchdog_active.json — + and before the __file__-derived root (2026-08-31) they wrote wherever the + process stood, which on CI's composed runner was the repo root. Module- + local autouse, per this branch's conftest philosophy: the registry pin + tests measure _default_storage_path itself and must not inherit a patch. + """ + monkeypatch.setattr( + watch_registry, + "_default_storage_path", + lambda: tmp_path / "watchdog_active.json", + ) # ───────────────────────────────────────────────────────────────────────────── @@ -767,3 +785,72 @@ def test_watch_agent_crash_path_skipped(): branch via the same code path the monitor uses. """ pytest.skip("Crash path covered by unit test test_watch_agent_crashed_via_bounce_file") + + +def _case_insensitive_glob(monkeypatch): + """Emulate a Windows directory listing on any OS: *_REGISTRY.json also + matches *_registry.json there, because NTFS compares names case-folded. + The wrapper widens the real glob the same way, so the exact-case filter + in agent.py can be proven load-bearing from a Linux box.""" + real_glob = agent_handler.Path.glob + + def widened(self, pattern): + if pattern.endswith("_REGISTRY.json"): + folded = pattern[: -len("_REGISTRY.json")] + "_registry.json" + yield from real_glob(self, folded) + yield from real_glob(self, pattern) + + monkeypatch.setattr(agent_handler.Path, "glob", widened) + + +def test_lowercase_counter_never_answers_a_projects_resolve(monkeypatch, tmp_path): + """A lowercase *_registry.json (a plan counter, a template registry) must + never be read as a project's trust anchor, even where the filesystem + globs case-insensitively. The decoy sorts FIRST and maps the same email + to a wrong directory — without the exact-case re-check it wins.""" + (tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps({"branches": []}), encoding="utf-8") + wrong_dir = tmp_path / "projects" / "aaa" / "wrong" + wrong_dir.mkdir(parents=True) + (tmp_path / "projects" / "aaa" / "aaa_registry.json").write_text( + json.dumps({"branches": [{"name": "AAA", "email": "@baud", "path": "wrong"}]}), + encoding="utf-8", + ) + right_dir = tmp_path / "projects" / "baud" / "home" + right_dir.mkdir(parents=True) + (tmp_path / "projects" / "baud" / "BAUD_REGISTRY.json").write_text( + json.dumps({"branches": [{"name": "BAUD", "email": "@baud", "path": "home"}]}), + encoding="utf-8", + ) + + _case_insensitive_glob(monkeypatch) + widened = [p.name for p in (tmp_path / "projects").glob("*/*_REGISTRY.json")] + assert "aaa_registry.json" in widened, "emulation is blind — this test proves nothing" + + monkeypatch.setattr(agent_handler, "_find_repo_root", lambda *a, **kw: tmp_path) + monkeypatch.setenv("AIPASS_CALLER_CWD", "") + monkeypatch.setattr(agent_handler.Path, "home", lambda: tmp_path / "nohome") + + resolved = agent_handler._resolve_branch_path("@baud") + assert resolved is not None + assert resolved.resolve() == right_dir.resolve() + + +def test_lowercase_counter_never_answers_an_external_resolve(monkeypatch, tmp_path): + """Same defect, the external-roots walk: a lowercase counter in a caller's + project must not resolve a branch at all — absence is the right answer.""" + ext = tmp_path / "ext" + (ext / "somewhere").mkdir(parents=True) + (ext / "ext_registry.json").write_text( + json.dumps({"branches": [{"name": "EXT", "email": "@ext", "path": "somewhere"}]}), + encoding="utf-8", + ) + + _case_insensitive_glob(monkeypatch) + widened = [p.name for p in ext.glob("*_REGISTRY.json")] + assert "ext_registry.json" in widened, "emulation is blind — this test proves nothing" + + monkeypatch.setattr(agent_handler, "_find_repo_root", lambda *a, **kw: None) + monkeypatch.setenv("AIPASS_CALLER_CWD", str(ext)) + monkeypatch.setattr(agent_handler.Path, "home", lambda: tmp_path / "nohome") + + assert agent_handler._resolve_branch_path("@ext") is None diff --git a/src/aipass/devpulse/tests/test_watchdog_registry.py b/src/aipass/devpulse/tests/test_watchdog_registry.py index 7fc4fadb3..83a4caf86 100644 --- a/src/aipass/devpulse/tests/test_watchdog_registry.py +++ b/src/aipass/devpulse/tests/test_watchdog_registry.py @@ -13,6 +13,7 @@ import subprocess import sys import time +from pathlib import Path from unittest.mock import patch import pytest @@ -453,3 +454,49 @@ def exploding_sleep(duration): # Even though wake_in raised, the finally block must have deregistered. assert watch_registry.list_active(storage_path=store_path, prune_stale=False) == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# default storage path — where the store lands when nobody passes one + + +class TestTheDefaultStoreNeverLandsAtTheCallersFeet: + """The 2026-08-31 CI red: on a fresh checkout (no AIPASS_REGISTRY.json + anywhere — the marker is machine-local runtime state, exactly like the + hermetic_mail_doors story one fixture up) the old walk found nothing and + fell back to Path.cwd(). The composed runner hands every child cwd = repo + root, so an unrelated test exercising agent.watch planted .watchdog/ in + the tree — convicted by seedgo's left-nothing-behind fixture on its first + live catch. The store is package state: it belongs where the package + lives, derived from __file__, the same answer on a dev machine, a fresh + checkout, and a dead cwd. + """ + + def test_the_default_path_ignores_cwd_entirely(self, tmp_path, monkeypatch): + """From a marker-less cwd the default must NOT be under that cwd. + + Red against the old code: the walk fails (no marker above tmp_path, + no parent named devpulse) and the fallback answers tmp_path/.watchdog. + """ + from aipass.devpulse.apps.handlers.watchdog import registry as reg + from aipass.devpulse.apps.handlers.watchdog import timer as tmr + + monkeypatch.chdir(tmp_path) + for mod in (reg, tmr): + default = mod._default_storage_path() + assert not default.is_relative_to(tmp_path), ( + f"the default store landed at the caller's feet — the cwd fallback is back: {default}" + ) + + def test_the_default_path_lives_inside_the_package(self, tmp_path, monkeypatch): + """The store resolves next to the code that owns it, from anywhere.""" + from aipass.devpulse.apps.handlers.watchdog import registry as reg + from aipass.devpulse.apps.handlers.watchdog import timer as tmr + + monkeypatch.chdir(tmp_path) + package_root = Path(reg.__file__).resolve().parents[3] + assert package_root.name == "devpulse", ( + f"the layout assumption this test rests on moved — re-derive parents[N]: {package_root}" + ) + assert reg._default_storage_path() == (package_root / ".watchdog" / "watchdog_active.json") + assert tmr._default_storage_path() == (package_root / ".watchdog" / "watchdog_timers.json") diff --git a/src/aipass/drone/.seedgo/bypass.json b/src/aipass/drone/.seedgo/bypass.json index aaaf0aa2b..0cbe3b3ab 100644 --- a/src/aipass/drone/.seedgo/bypass.json +++ b/src/aipass/drone/.seedgo/bypass.json @@ -5,53 +5,30 @@ "description": "Standards bypass configuration for this branch" }, "bypass": [ - { - "file": "apps/drone.py", - "standard": "cli", - "lines": [ - 287, - 289, - 407, - 409, - 566, - 568, - 685, - 687 - ], - "reason": "Raw passthrough of routed command stdout/stderr \u2014 console.print() wraps at 80 cols when piped, corrupting JSON and machine output" - }, - { - "file": "apps/modules/router.py", - "standard": "cli", - "lines": [ - 62 - ], - "reason": "Raw passthrough of routed command stdout \u2014 console.print() wraps at 80 cols when piped, corrupting JSON and machine output" - }, { "file": "apps/handlers/scanning/formatters.py", "standard": "cli", - "reason": "Formatter handler \u2014 purpose is to format and print Rich output. console.print() is the core function, not a violation." + "reason": "Formatter handler — purpose is to format and print Rich output. console.print() is the core function, not a violation." }, { "file": "apps/handlers/command_registry/formatters.py", "standard": "cli", - "reason": "Formatter handler \u2014 purpose is to format and print Rich output. console.print() is the core function, not a violation." + "reason": "Formatter handler — purpose is to format and print Rich output. console.print() is the core function, not a violation." }, { "file": "apps/modules/git_module.py", "standard": "modules", - "reason": "handle_command returns dict (not bool) \u2014 module doubles as drone adapter for route_module_command() which requires dict return with stdout/stderr/exit_code" + "reason": "handle_command returns dict (not bool) — module doubles as drone adapter for route_module_command() which requires dict return with stdout/stderr/exit_code" }, { "file": "apps/handlers/module_registry_handler.py", "standard": "naming", - "reason": "_ExternalModuleConfig is PascalCase \u2014 leading underscore is Python private convention, not a naming violation" + "reason": "_ExternalModuleConfig is PascalCase — leading underscore is Python private convention, not a naming violation" }, { "file": "apps/plugins/devpulse_ops/auth.py", "standard": "architecture", - "reason": "Plugin lives in apps/plugins/ by design \u2014 separate from 3-layer structure. Plugins are an intentional extension point." + "reason": "Plugin lives in apps/plugins/ by design — separate from 3-layer structure. Plugins are an intentional extension point." }, { "file": "apps/handlers/git/pr_handler.py", @@ -61,32 +38,32 @@ { "file": "apps/plugins/devpulse_ops/merge_plugin.py", "standard": "architecture", - "reason": "Plugin lives in apps/plugins/ by design \u2014 separate from 3-layer structure." + "reason": "Plugin lives in apps/plugins/ by design — separate from 3-layer structure." }, { "file": "apps/plugins/devpulse_ops/merge_plugin.py", "standard": "encapsulation", - "reason": "Plugin imports lock_handler.find_repo_root() \u2014 drone-internal plugin, same branch." + "reason": "Plugin imports lock_handler.find_repo_root() — drone-internal plugin, same branch." }, { "file": "apps/plugins/devpulse_ops/sync_plugin.py", "standard": "architecture", - "reason": "Plugin lives in apps/plugins/ by design \u2014 separate from 3-layer structure." + "reason": "Plugin lives in apps/plugins/ by design — separate from 3-layer structure." }, { "file": "apps/plugins/devpulse_ops/sync_plugin.py", "standard": "encapsulation", - "reason": "Plugin imports lock_handler.find_repo_root() \u2014 drone-internal plugin, same branch." + "reason": "Plugin imports lock_handler.find_repo_root() — drone-internal plugin, same branch." }, { "file": "apps/plugins/devpulse_ops/fix_plugin.py", "standard": "architecture", - "reason": "Plugin lives in apps/plugins/ by design \u2014 separate from 3-layer structure." + "reason": "Plugin lives in apps/plugins/ by design — separate from 3-layer structure." }, { "file": "apps/plugins/devpulse_ops/fix_plugin.py", "standard": "encapsulation", - "reason": "Plugin imports lock_handler.find_repo_root() \u2014 drone-internal plugin, same branch." + "reason": "Plugin imports lock_handler.find_repo_root() — drone-internal plugin, same branch." }, { "file": "tests/test_devpulse_plugins.py", @@ -96,7 +73,7 @@ { "file": "artifacts/broker_acceptance_test.py", "standard": "architecture", - "reason": "Acceptance test artifact \u2014 standalone demo script, not part of 3-layer production structure." + "reason": "Acceptance test artifact — standalone demo script, not part of 3-layer production structure." }, { "file": "artifacts/broker_acceptance_test.py", @@ -106,7 +83,7 @@ { "file": "artifacts/broker_acceptance_test.py", "standard": "documentation", - "reason": "Acceptance test script \u2014 main() is self-documenting via module docstring and inline comments." + "reason": "Acceptance test script — main() is self-documenting via module docstring and inline comments." }, { "file": "artifacts/broker_acceptance_test.py", @@ -116,22 +93,22 @@ { "file": "artifacts/broker_acceptance_test.py", "standard": "help_text", - "reason": "Docstring run instruction shows how to invoke the script \u2014 not a production help text." + "reason": "Docstring run instruction shows how to invoke the script — not a production help text." }, { "file": "artifacts/broker_acceptance_test.py", "standard": "meta", - "reason": "Acceptance test artifact \u2014 META blocks are for production source files." + "reason": "Acceptance test artifact — META blocks are for production source files." }, { "file": "artifacts/broker_acceptance_test.py", "standard": "trigger", - "reason": "Acceptance test exercises .unlink() to clean up test symlinks \u2014 not production file operations." + "reason": "Acceptance test exercises .unlink() to clean up test symlinks — not production file operations." }, { "file": "artifacts/broker_acceptance_test.py", "standard": "unused_function", - "reason": "Standalone acceptance demo runner \u2014 helper functions invoked from the demo main, not a production module (same pattern as the other demo bypasses)." + "reason": "Standalone acceptance demo runner — helper functions invoked from the demo main, not a production module (same pattern as the other demo bypasses)." }, { "file": "apps/handlers/command_registry/ops.py", @@ -140,7 +117,7 @@ "update_command", "command_exists" ], - "reason": "Public CRUD API surface (update_command, command_exists) \u2014 tested, available for programmatic use. No CLI subcommand wired yet." + "reason": "Public CRUD API surface (update_command, command_exists) — tested, available for programmatic use. No CLI subcommand wired yet." }, { "file": "apps/handlers/git/pr_handler.py", @@ -148,7 +125,7 @@ "functions": [ "create_pr" ], - "reason": "create_pr() has no production caller \u2014 the `pr` command is owner-tier and live, but _handle_pr() in git_module.py dispatches to dev_pr_handler.create_branch_pr(), not here. Its only callers are 7 sites in tests/test_git_module.py. Kept pending a removal decision (APLAN-0003); this bypass documents orphaned code, it does not defend it." + "reason": "create_pr() has no production caller — the `pr` command is owner-tier and live, but _handle_pr() in git_module.py dispatches to dev_pr_handler.create_branch_pr(), not here. Its only callers are 7 sites in tests/test_git_module.py. Kept pending a removal decision (APLAN-0003); this bypass documents orphaned code, it does not defend it." }, { "file": "apps/modules/git_module.py", @@ -172,22 +149,23 @@ "lines": [ 27 ], - "reason": "Line 27 imports AIPASS_REGISTRY_NAME from handlers/git/repo_context.py \u2014 drone-internal, same branch, same subsystem (merge/sync/fix_plugin hold the identical rule for lock_handler). The alternative is a second literal of the registry filename: the auth gate and the tag handler must agree on which repo is AIPass's own, and two copies of that fact can drift. Line-scoped on purpose \u2014 auth.py is the git authority gate and any OTHER direct handler import in it must still be reported." + "reason": "Line 27 imports AIPASS_REGISTRY_NAME from handlers/git/repo_context.py — drone-internal, same branch, same subsystem (merge/sync/fix_plugin hold the identical rule for lock_handler). The alternative is a second literal of the registry filename: the auth gate and the tag handler must agree on which repo is AIPass's own, and two copies of that fact can drift. Line-scoped on purpose — auth.py is the git authority gate and any OTHER direct handler import in it must still be reported. ANCHORED 2026-08-31: the 'anchor' key states what line 27 must CONTAIN, and TestLineScopedBypassesAreOnTarget fails the day it does not. A line number is a pointer that edits invalidate silently — @trigger had four shift in round 5 and @seedgo measured drone's other nine adrift — so a waiver that cannot say what it points at cannot be trusted to still point there.", + "anchor": "from aipass.drone.apps.handlers.git.repo_context import AIPASS_REGISTRY_NAME" }, { "file": "apps/handlers/git/repo_context.py", "standard": "json_structure", - "reason": "Pure predicate \u2014 one constant and one boolean over the filesystem, no operation to log. A log_operation() call here would fire on every git command to record 'a question was asked'; the callers that ACT on the answer (tag_handler, auth) log their own outcomes. Wiring json_handler in to satisfy the checker would be scaffolding, not logging." + "reason": "Pure predicate — one constant and one boolean over the filesystem, no operation to log. A log_operation() call here would fire on every git command to record 'a question was asked'; the callers that ACT on the answer (tag_handler, auth) log their own outcomes. Wiring json_handler in to satisfy the checker would be scaffolding, not logging." }, { "file": "apps/handlers/help_flags.py", "standard": "json_structure", - "reason": "Pure predicate \u2014 one question over a token list, no I/O and no state. It is called before every command in ten modules, so a log_operation() here would record 'a help flag was looked for' on every single invocation and drown the operations log in non-events. The callers that ACT on the answer already log what they did. Same shape and same reasoning as the repo_context.py rule." + "reason": "Pure predicate — one question over a token list, no I/O and no state. It is called before every command in ten modules, so a log_operation() here would record 'a help flag was looked for' on every single invocation and drown the operations log in non-events. The callers that ACT on the answer already log what they did. Same shape and same reasoning as the repo_context.py rule." }, { "file": "apps/handlers/json_flags.py", "standard": "json_structure", - "reason": "Pure predicate \u2014 one question over a token list, no I/O and no state. The literal twin of help_flags.py, which carries this same rule for the same reason: it runs before every git read command, so a log_operation() here would record 'a --json flag was looked for' on every single invocation and drown the operations log in non-events. The callers that ACT on the answer (status, log, show, remote) already log their own outcomes. Adding json_handler to satisfy a checker that keys on the directory, not on whether the file touches JSON data, would be scaffolding rather than logging." + "reason": "Pure predicate — one question over a token list, no I/O and no state. The literal twin of help_flags.py, which carries this same rule for the same reason: it runs before every git read command, so a log_operation() here would record 'a --json flag was looked for' on every single invocation and drown the operations log in non-events. The callers that ACT on the answer (status, log, show, remote) already log their own outcomes. Adding json_handler to satisfy a checker that keys on the directory, not on whether the file touches JSON data, would be scaffolding rather than logging." } ], "notes": { @@ -204,7 +182,8 @@ "pattern": "Optional - pattern to match (e.g. 'if __name__')", "reason": "Required - why this bypass exists" }, - "scoped_rules_trued_2026-08-09": "FPLAN-0382 wave 1: all 12 scope-qualified rules re-verified against the tree before seedgo's is_bypassed() scoping fix lands. Every line-scoped rule had DRIFTED \u2014 the 3 kept were refreshed, and 5 were DELETED as dead (3 handlers rules whose imports are same-branch aipass.drone, dead since dc07cc01; test_git_module encapsulation; test_broker windows_compat, already covered by an inline noqa). Deletion was proven, not assumed: removing each rule and re-auditing produced no violation. The 3 survivors were checked the other way \u2014 with the rule removed, the checker reports violations at exactly the bypassed lines and nowhere else, so line-scoped enforcement will not expose anything the current whole-file behaviour is hiding. All 4 function-scoped rules still name existing functions; reasons hold. Line numbers are a claim about the tree \u2014 re-verify whenever the file moves.", - "removed_s61": "13 naming bypasses removed (9 __all__ + 4 local variable false positives). Seedgo dispatched to fix naming_check.py. If audit fails on naming after this, the checker fix didn't land." + "scoped_rules_trued_2026-08-09": "FPLAN-0382 wave 1: all 12 scope-qualified rules re-verified against the tree before seedgo's is_bypassed() scoping fix lands. Every line-scoped rule had DRIFTED — the 3 kept were refreshed, and 5 were DELETED as dead (3 handlers rules whose imports are same-branch aipass.drone, dead since dc07cc01; test_git_module encapsulation; test_broker windows_compat, already covered by an inline noqa). Deletion was proven, not assumed: removing each rule and re-auditing produced no violation. The 3 survivors were checked the other way — with the rule removed, the checker reports violations at exactly the bypassed lines and nowhere else, so line-scoped enforcement will not expose anything the current whole-file behaviour is hiding. All 4 function-scoped rules still name existing functions; reasons hold. Line numbers are a claim about the tree — re-verify whenever the file moves.", + "removed_s61": "13 naming bypasses removed (9 __all__ + 4 local variable false positives). Seedgo dispatched to fix naming_check.py. If audit fails on naming after this, the checker fix didn't land.", + "line_scoped_anchored_2026-08-31": "The two cli line-scoped entries (apps/drone.py 8 numbers, apps/modules/router.py 1) were DELETED, not re-derived. @seedgo measured all nine adrift (+8/+13 in drone.py, +4 in router.py) and, more to the point, redundant: cli_check learned the relayed-stream exemption in round two, so both files score 100 on their own merits. Verified by A/B here before deleting — removing both entries left cli at 100 and violations at 0. Under the fleet rule (if the exemption is structurally detectable, the checker learns it; a waiver is only for what cannot be measured) they were dead weight, and nine numbers that would suppress the WRONG lines if they ever matched again, in the file that routes every command in the system. auth.py:27 was on target and stays, now with a content anchor and a test." } } diff --git a/src/aipass/drone/apps/drone.py b/src/aipass/drone/apps/drone.py index 399324f01..f98c88c6c 100644 --- a/src/aipass/drone/apps/drone.py +++ b/src/aipass/drone/apps/drone.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: drone.py # Description: Drone - Command Router & Discovery -# Version: 1.1.1 +# Version: 1.2.1 # Created: 2026-03-05 -# Modified: 2026-08-11 +# Modified: 2026-08-31 # ============================================= """ @@ -33,7 +33,7 @@ from aipass.cli.apps.modules import console, err_console from aipass.drone.apps.modules import BranchNotFoundError, CommandExecutionError, RegistryError from aipass.drone.apps.modules.resolver import branch_exists, get_all_branches -from aipass.drone.apps.modules.router import route_command +from aipass.drone.apps.modules.router import caller_cwd, registries_in, route_command from aipass.drone.apps.modules.module_registry import ( is_module, list_modules, @@ -181,18 +181,26 @@ def print_introspection() -> None: def _cwd_has_registry(max_depth: int = 10) -> bool: - """Check if CWD is within a project that has a *_REGISTRY.json.""" - cwd = Path.cwd() - for i, parent in enumerate([cwd] + list(cwd.parents)): - if i >= max_depth: - break - if list(parent.glob("*_REGISTRY.json")): - return True + """Check if CWD is within a project that has a *_REGISTRY.json. + + A caller whose directory was deleted has no CWD to be "within", so the walk + is skipped and AIPASS_HOME — which never needed a location — still answers. + """ + # No location is not a failure to read one. ``caller_cwd`` is the single + # reader (router_handler); a private try/except here would be the tenth copy + # of the same three lines, which is the species this sweep closed. + cwd = caller_cwd() + if cwd is not None: + for i, parent in enumerate([cwd] + list(cwd.parents)): + if i >= max_depth: + break + if registries_in(parent): + return True # AIPASS_HOME fallback — for external projects / global drone usage aipass_home = os.environ.get("AIPASS_HOME") if aipass_home: home = Path(aipass_home) - if home.is_dir() and list(home.glob("*_REGISTRY.json")): + if home.is_dir() and registries_in(home): return True return False @@ -445,8 +453,13 @@ def _find_seat_inbox() -> Path | None: Stops at the first directory holding a passport — that seat's inbox is the only one this caller may address, whether or not the file exists yet. + + No CWD means no seat: the seat is inferred from where the caller stands, + and a caller standing nowhere addresses nothing. """ - cwd = Path.cwd() + cwd = caller_cwd() + if cwd is None: + return None for parent in [cwd] + list(cwd.parents): if not (parent / ".trinity" / "passport.json").exists(): continue diff --git a/src/aipass/drone/apps/handlers/__init__.py b/src/aipass/drone/apps/handlers/__init__.py index 43ab76e54..96245c100 100644 --- a/src/aipass/drone/apps/handlers/__init__.py +++ b/src/aipass/drone/apps/handlers/__init__.py @@ -1,11 +1,29 @@ """Drone handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "aipass.drone" +def _safe_resolve(path: Path) -> Path: + """``resolve()`` where the raw spelling is an acceptable answer. + + ``Path.resolve()`` routes through ``os.path.realpath``, and ``ntpath``'s + copy of that reads ``os.getcwd()`` on its first lines — unconditionally, + before it even asks whether the path is absolute. A Windows process whose + working directory is gone therefore dies inside a call that only meant to + normalise an already-absolute path. Module ``__file__`` and frame + ``co_filename`` are absolute in every layout drone ships in, so dropping + the normalisation costs symlink-flattening and nothing else. + """ + try: + return path.resolve() + except OSError: + return path + + def _find_real_caller(): """ Walk the stack to find the actual file that triggered this import. @@ -16,32 +34,62 @@ def _find_real_caller(): - Frozen modules Returns tuple: (file_path, import_line) or (None, None) + + Walks frames with ``sys._getframe`` rather than ``inspect.stack()``. + MEASURED on the Windows CI gate 2026-08-31: ``inspect.stack()`` needs a + readable working directory, and it needs one before any of this function's + own code runs. It builds a ``FrameInfo`` per frame, which reaches + ``getmodule()``, whose ``os.path.realpath(f)`` sits outside every ``try`` + in that function. On POSIX the equivalent raise happens earlier, inside + ``getabsfile()``, where ``inspect`` catches it — which is why a guard that + ran on every drone import survived years of Linux CI carrying this. A + frame's ``co_filename`` is already a string in memory; reading it touches + no filesystem at all. """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) + this_file = str(_safe_resolve(Path(__file__))) + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals FIRST. These names are not paths — every import + # stack carries — and resolving one goes + # through realpath, which READS THE CURRENT DIRECTORY. A process whose + # cwd was deleted (an ordinary state since `drone rm` learned to delete + # the directory you stand in) then raised ENOENT here, at import of the + # handlers package, before any command could run. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back + continue - for frame_info in stack: - filename = frame_info.filename + # A relative frame filename has no cwd to resolve against. Fall back to + # the raw spelling rather than taking the whole import down. + resolved = str(_safe_resolve(Path(filename))) # Skip this file - if this_file in str(Path(filename).resolve()): + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - # Skip Python internals - if filename.startswith("<") or "importlib" in filename: - continue + # Found a real file - try to get the import line. linecache is what + # inspect used to build code_context; called directly it reads one + # named file and returns "" rather than raising when it cannot. + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - # Found a real file - try to get the import line - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() - - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None -_BRANCH_ROOT = str(Path(__file__).resolve().parents[2]) +# The branch this package belongs to, named by its own location. Both spellings +# are kept: a resolve that FAILED must not be able to make a same-branch caller +# look foreign, and the unresolved form is the same directory with symlinks left +# standing — it can only ever admit drone's own tree under a second name. +_BRANCH_ROOT_RAW = str(Path(__file__).parents[2]) +_BRANCH_ROOT = str(_safe_resolve(Path(__file__)).parents[2]) def _extract_branch_name(filepath: str) -> str: @@ -65,27 +113,24 @@ def _guard_branch_access(): import os if os.environ.get("AIPASS_DEBUG_GUARD"): - import sys - sys.stderr.write(f"[GUARD DEBUG] caller_file = {caller_file}\n") sys.stderr.write(f"[GUARD DEBUG] import_line = {import_line}\n") if caller_file is None: - # Can't determine caller from real files - # Check if we're being run from command line (external) - # by looking at the raw stack for or - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return # Allow command-line Python through - return # Allow if truly can't determine - - if Path(caller_file).is_relative_to(_BRANCH_ROOT): + # No caller outside this file: an interactive session, a -c script, or + # an importlib-only stack. All three are allowed. This used to walk + # inspect.stack() a SECOND time looking for / and then + # return either way — a second copy of the cwd dependency above, in + # service of a branch that could not change the answer. + return + + caller = Path(caller_file) + if caller.is_relative_to(_BRANCH_ROOT) or caller.is_relative_to(_BRANCH_ROOT_RAW): return # Same branch, allowed # External caller - block access caller_branch = _extract_branch_name(caller_file) - caller_filename = Path(caller_file).name + caller_filename = caller.name blocked_import = import_line if import_line else "unknown" module_api = f"{MY_BRANCH}.apps.modules" diff --git a/src/aipass/drone/apps/handlers/broker/daemon.py b/src/aipass/drone/apps/handlers/broker/daemon.py index bdee099e9..b5f74fcb0 100644 --- a/src/aipass/drone/apps/handlers/broker/daemon.py +++ b/src/aipass/drone/apps/handlers/broker/daemon.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: daemon.py # Description: Broker daemon — privileged deleter for sandboxed agents -# Version: 2.0.0 +# Version: 2.1.1 # Created: 2026-06-09 -# Modified: 2026-06-10 +# Modified: 2026-08-31 # ============================================= """Broker daemon — privileged deleter for sandboxed agents. @@ -40,6 +40,7 @@ from aipass.drone.apps.handlers.broker.protocol import BrokerRequest, BrokerResponse from aipass.drone.apps.handlers.broker.path_resolver import resolve_beneath from aipass.drone.apps.handlers import deletion_log +from aipass.drone.apps.handlers.router_handler import caller_cwd, registries_in _DEFAULT_SOCKET_DIR = ".ai_central" _SOCKET_NAME = "drone_broker.sock" @@ -52,15 +53,24 @@ def _find_project_root() -> Path | None: - """Walk up from CWD to find *_REGISTRY.json; return its parent as project root.""" - cwd = Path.cwd() - for parent in [cwd, *cwd.parents]: - if list(parent.glob("*_REGISTRY.json")): - return parent.resolve() + """Walk up from CWD to find *_REGISTRY.json; return its parent as project root. + + A byte-for-byte twin of ``rm_handler._find_project_root`` and it carried the + identical unguarded read. Left as two functions rather than merged: the + broker is a long-lived daemon and the rm lane is a one-shot command, so a + shared root-finder would couple their lifetimes for the sake of six lines. + They are pinned together by ``TestTheSweepIsComplete`` instead, which is + what actually keeps them from drifting apart again. + """ + cwd = caller_cwd() + if cwd is not None: + for parent in [cwd, *cwd.parents]: + if registries_in(parent): + return parent.resolve() aipass_home = os.environ.get("AIPASS_HOME") if aipass_home: home = Path(aipass_home) - if home.is_dir() and list(home.glob("*_REGISTRY.json")): + if home.is_dir() and registries_in(home): return home.resolve() return None diff --git a/src/aipass/drone/apps/handlers/command_registry/ops.py b/src/aipass/drone/apps/handlers/command_registry/ops.py index 61a84b8f2..9e65e5344 100644 --- a/src/aipass/drone/apps/handlers/command_registry/ops.py +++ b/src/aipass/drone/apps/handlers/command_registry/ops.py @@ -30,6 +30,7 @@ from aipass.prax import logger from aipass.drone.apps.handlers.json import json_handler +from aipass.drone.apps.handlers.module_root import module_file # --------------------------------------------------------------------------- # Constants @@ -38,7 +39,7 @@ MODULE_NAME = "command_registry" # ops.py -> command_registry/ -> handlers/ -> apps/ -> drone/ -_BRANCH_ROOT: Path = Path(__file__).resolve().parents[3] +_BRANCH_ROOT: Path = module_file(__file__).parents[3] REGISTRY_FILE: Path = _BRANCH_ROOT / "drone_command_registry.json" diff --git a/src/aipass/drone/apps/handlers/deletion_log.py b/src/aipass/drone/apps/handlers/deletion_log.py index 13d52a0f4..80ede1696 100644 --- a/src/aipass/drone/apps/handlers/deletion_log.py +++ b/src/aipass/drone/apps/handlers/deletion_log.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: deletion_log.py # Description: Durable record of every delete drone performs -# Version: 1.0.0 +# Version: 1.0.1 # Created: 2026-08-14 -# Modified: 2026-08-14 +# Modified: 2026-08-31 # ============================================= """Durable record of every delete drone performs. @@ -43,7 +43,7 @@ from aipass.prax import logger from aipass.drone.apps.handlers.json import json_handler -from aipass.drone.apps.handlers.router_handler import resolve_caller_identity +from aipass.drone.apps.handlers.router_handler import caller_cwd, registries_in, resolve_caller_identity _LOG_DIR_NAME = ".ai_central" _LOG_NAME = "deletions.jsonl" @@ -74,17 +74,30 @@ UNKNOWN_CALLER = "unknown" +# What the cwd field records when the process has no working directory left. +# Deleting the directory you are standing in is an ordinary thing to do with a +# scratch dir, and every os.getcwd() after it raises ENOENT. A defined value +# for "there is none" is not a substitute answer — inventing "/" or reusing the +# deleted path would be, and both would read as a real location months later. +NO_CURRENT_DIRECTORY = "" + def _find_project_root() -> Path | None: - """Walk up from CWD to find *_REGISTRY.json; return its parent as project root.""" - cwd = Path.cwd() - for parent in [cwd, *cwd.parents]: - if list(parent.glob("*_REGISTRY.json")): + """Walk up from CWD to find *_REGISTRY.json; return its parent as project root. + + No cwd means no walk — and the ENOENT used to raise here, past the two + homes that could still have answered. AIPASS_HOME and the tempdir below + do not need a cwd, so a delete that removes its own directory keeps the + durable half of its record instead of surviving as a prax line alone. + """ + cwd = caller_cwd() + for parent in [cwd, *cwd.parents] if cwd is not None else []: + if registries_in(parent): return parent.resolve() aipass_home = os.environ.get("AIPASS_HOME") if aipass_home: home = Path(aipass_home) - if home.is_dir() and list(home.glob("*_REGISTRY.json")): + if home.is_dir() and registries_in(home): return home.resolve() return None @@ -243,7 +256,8 @@ def record_deletion( Never raises. A failed record is reported at ERROR and the caller carries on: losing the log must not turn into losing the delete. """ - caller = caller or resolve_caller_identity(Path.cwd()) or UNKNOWN_CALLER + cwd = caller_cwd() + caller = caller or resolve_caller_identity(cwd) or UNKNOWN_CALLER shape = measurement or {"kind": "unknown", "size_bytes": None, "entry_count": None, "measured": "none"} record = { @@ -251,7 +265,7 @@ def record_deletion( "lane": lane, "outcome": outcome, "caller": caller, - "cwd": str(Path.cwd()), + "cwd": str(cwd) if cwd is not None else NO_CURRENT_DIRECTORY, "requested": requested, "path": str(resolved), "reason": reason, diff --git a/src/aipass/drone/apps/handlers/exceptions.py b/src/aipass/drone/apps/handlers/exceptions.py index 0486ce8a0..cbcdf58a9 100644 --- a/src/aipass/drone/apps/handlers/exceptions.py +++ b/src/aipass/drone/apps/handlers/exceptions.py @@ -12,9 +12,14 @@ Defines the exception hierarchy for routing and branch resolution errors. """ -from aipass.drone.apps.handlers.json import json_handler - -json_handler.log_operation("exceptions_loaded", module_name="exceptions") +# This module deliberately records nothing when it loads. It used to call +# log_operation("exceptions_loaded") at module level, which wrote into +# drone_json on every import — including during pytest COLLECTION, before any +# fixture exists, so neither the repo-root conftest's autouse guard nor +# AIPASS_TEST_LOG_DIR could intercept it. It was the last live-tree write left +# after the seam was adopted, and it recorded nothing a reader wanted: that an +# exception class file was imported. Defining exceptions is not an operation. +# Raising one is, and the raiser logs it. class RoutingError(Exception): diff --git a/src/aipass/drone/apps/handlers/git/lock_handler.py b/src/aipass/drone/apps/handlers/git/lock_handler.py index 2442e5314..cf5de92be 100644 --- a/src/aipass/drone/apps/handlers/git/lock_handler.py +++ b/src/aipass/drone/apps/handlers/git/lock_handler.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: lock_handler.py # Description: Atomic lock management for git PR workflow -# Version: 1.0.0 +# Version: 1.1.1 # Created: 2026-03-17 -# Modified: 2026-03-17 +# Modified: 2026-08-31 # ============================================= """ @@ -25,10 +25,16 @@ from aipass.prax import logger from aipass.drone.apps.handlers.json import json_handler +from aipass.drone.apps.handlers.router_handler import caller_cwd, registries_in _LOCK_FILENAME = ".git_pr.lock" _STALE_THRESHOLD_SECONDS = 600 +# What marks a project root when no registry file exists to mark it — the same +# set find_registry() falls back to, because a second opinion on "where is the +# root" is how two answers to one question start disagreeing. +_PROJECT_MARKERS = (".git", "pyproject.toml", "setup.py", "setup.cfg") + def _pid_alive_windows(pid: int) -> bool: """Windows-safe liveness check via OpenProcess + GetExitCodeProcess.""" @@ -86,16 +92,44 @@ def find_repo_root() -> Path: Any ``*_REGISTRY.json`` marks a project root, not just AIPass's own — external projects name theirs after themselves (VERA-STUDIO_REGISTRY.json), - and hardcoding the AIPass name sent them down the rev-parse fallback while - registry resolution found the real root, so the two could disagree. + and hardcoding the AIPass name sent them down the toplevel-query fallback + while registry resolution found the real root, so the two could disagree. + + NO-CWD PATH. This returns ``Path``, not ``Optional[Path]``, so unlike its + siblings in this sweep it cannot answer "unknown" — callers build a lock + path out of it. Both location-derived sources lose their starting point at + once (the walk has nowhere to start, and the toplevel query would be handed + the same dead directory), so the answer falls to the two sources that never + needed a location: AIPASS_HOME, then the walk up from this file. That is + ``find_registry``'s existing precedence, reused rather than re-invented — + two orders for one question is how the answers start disagreeing. """ - cwd = Path.cwd() + cwd = caller_cwd() current = cwd - while current != current.parent: - if any(current.glob("*_REGISTRY.json")): + while current is not None and current != current.parent: + if registries_in(current): return current current = current.parent + if cwd is None: + aipass_home = os.environ.get("AIPASS_HOME") + if aipass_home: + home = Path(aipass_home) + if home.is_dir() and registries_in(home): + return home + here = Path(__file__).resolve() + for parent in here.parents: + if registries_in(parent): + return parent + # Last resort, and the world CI actually runs in: a clean checkout has + # no registry anywhere (it is gitignored and machine-local). Marker-based, + # the same markers find_registry falls back to. Never the deleted + # directory, which is the one place a lock could not be written. + for parent in here.parents: + if any((parent / marker).exists() for marker in _PROJECT_MARKERS): + return parent + return here.parent + # Fallback: git rev-parse --show-toplevel try: result = subprocess.run( diff --git a/src/aipass/drone/apps/handlers/json/json_handler.py b/src/aipass/drone/apps/handlers/json/json_handler.py index d428b7947..805af353c 100644 --- a/src/aipass/drone/apps/handlers/json/json_handler.py +++ b/src/aipass/drone/apps/handlers/json/json_handler.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: json_handler.py # Description: JSON auto-creating handler for drone data files -# Version: 1.1.0 +# Version: 1.2.1 # Created: 2026-03-17 -# Modified: 2026-08-18 +# Modified: 2026-08-31 # ============================================= """JSON auto-creating handler for drone data files. @@ -14,7 +14,6 @@ from __future__ import annotations -import inspect import json import os import sys @@ -33,14 +32,95 @@ from aipass.prax import logger +from aipass.drone.apps.handlers.module_root import module_file + # --------------------------------------------------------------------------- # Infrastructure — auto-detect branch root from file location # json_handler.py -> json/ -> handlers/ -> apps/ -> drone/ # --------------------------------------------------------------------------- -_BRANCH_ROOT: Path = Path(__file__).resolve().parents[3] +_BRANCH_ROOT: Path = module_file(__file__).parents[3] _BRANCH_NAME: str = _BRANCH_ROOT.name # "drone" -JSON_DIR: Path = _BRANCH_ROOT / f"{_BRANCH_NAME}_json" + +# The real tree, captured once so an explicit patch can be told apart from it. +_IMPORT_TIME_JSON_DIR: Path = _BRANCH_ROOT / f"{_BRANCH_NAME}_json" + +# Kept as a module attribute because ~20 tests across this suite redirect state +# with monkeypatch.setattr(json_handler, "JSON_DIR", ...). Reading it directly +# is what put 4189 write records in this branch's hygiene artifact, so the path +# builders go through _current_json_dir() instead. +JSON_DIR: Path = _IMPORT_TIME_JSON_DIR + +# @prax's contract (2026-08-30), in @trigger's form. Adopted per branch in its +# OWN json_handler: five mocking techniques already existed and every one of +# them reaches nothing, so a sixth would be the problem rather than the fix. +_TEST_DIR_ENV_VAR = "AIPASS_TEST_LOG_DIR" + + +def _current_json_dir() -> Path: + r"""Where JSON state belongs RIGHT NOW — resolved per call, never at import. + + Measured on this tree before this existed: under pytest the env var held a + temp directory and the module constant STILL pointed at the live + ``drone_json``. Something imports this module before the conftest that sets + the variable runs, and a value captured at import cannot be redirected by + anything afterwards. It is the same defect as the unmockable logger, and a + seam that has to win an import race is not a seam. + + THE OVERRIDE TEST COMPARES AGAINST BOTH FIXED POINTS and holds nothing + stale, which is @prax's corrected contract after @daemon's 9 pins went green + alone and red in the full suite. A test that calls ``importlib.reload`` + while a monkeypatch is live has its teardown write the PRE-reload Path back + onto the POST-reload module: same value, different object. An identity check + then reports "explicitly patched" for the rest of the session and the + redirect dies in a branch that looks adopted — measured here at 3757 + resolutions per suite run before this was fixed. + + Comparing by value against the real directory ALONE is not sufficient in + general either: where a branch's import-time constant can itself be the + redirect target, the written-back value and the post-reload default differ + and value comparison reads "patched" too. Drone survives both orderings + because ``_IMPORT_TIME_JSON_DIR`` is env-INDEPENDENT — it is always the real + directory, never the redirect — and that precondition is load-bearing, so it + is stated here rather than left to be rediscovered. + + An override therefore counts only when it differs from the real directory + AND from the current redirect target. The cost, stated not hidden: patching + the dir to either of those two is indistinguishable from not patching at + all. Both resolve to the same path, so no answer changes. + + An EMPTY env value is absence, not a redirect. ``Path("") / "x"`` is + relative, so honouring it would scatter state wherever the process happens + to be standing. + + THE SECOND COMPARISON IS VALUE-NEUTRAL AND IS KEPT ANYWAY — @prax found this + by mutating their own copy, and it reproduces here: dropping + ``and current != default`` kills no test in this suite (1309 green with the + mutant). It cannot, on POSIX. The only branch the two forms disagree on is + ``current != real and current == default``, where one returns ``current`` + and the other ``default`` — and ``Path`` equality on POSIX means identical + string parts, so the returned path is the same path. Untested by + construction, not a gap in coverage. + + ONE ASYMMETRY MAKES KEEPING IT THE CHEAPER SIDE, and it is the reason this + is not simply dead weight: ``PurePath.__eq__`` compares case-folded on + Windows, so ``PureWindowsPath(r"C:\Temp\Drone_Json")`` equals + ``PureWindowsPath(r"c:\temp\drone_json")`` while ``str()`` of the two + differs. There, returning ``current`` instead of ``default`` yields the same + FILE under a different string — invisible to a write, visible in a log line + or in any assertion that compares paths as text. The clause also states the + two-fixed-point rule that three trees now implement identically, which is + worth more than removing a line that costs nothing. + """ + real = _IMPORT_TIME_JSON_DIR + test_dir = os.environ.get(_TEST_DIR_ENV_VAR) + default = Path(test_dir) / _BRANCH_NAME / f"{_BRANCH_NAME}_json" if test_dir else real + + current = Path(JSON_DIR) + if current != real and current != default: + return current + return default + _JSON_TYPES: tuple[str, ...] = ("config", "data", "log") @@ -61,16 +141,35 @@ def _get_caller_module_name() -> str: Walks past internal frames ([0] = this function, [1] = public function, [2] = actual caller) and returns the stem of the caller's filename. + Reads the frame directly rather than through ``inspect.stack()``. MEASURED + 2026-08-31 in the hostile world that emulates a Windows box with no working + directory: ``inspect.stack()`` builds a ``FrameInfo`` per frame, and for any + frame whose filename is a PSEUDO-file — ````, which every interpreter + ``-c`` invocation and every exec'd hook puts on the stack — it reaches + ``getmodule()``, whose ``os.path.realpath`` sits outside that function's + every ``try``. The whole call then raises ``FileNotFoundError``, so + ``log_operation`` — the audit line drone writes on essentially every + operation — took the caller down from inside its own logging. On POSIX the + equivalent raise happens earlier, where ``inspect`` catches it, which is why + this stood on Linux for as long as it existed. + + ``FrameInfo.filename`` is ``getsourcefile(frame) or getfile(frame)``, and + both fall back to ``co_filename`` for the frames this walk looks at, so the + stem is the same string by a route that touches no filesystem at all. + Returns: Module name (e.g. ``"flight_controller"`` from ``flight_controller.py``). """ - stack = inspect.stack() # Skip frames: [0]=this function, [1]=public wrapper, [2]=actual caller - if len(stack) > 2: - caller_path = Path(stack[2].filename) - module_name = caller_path.stem - if module_name and not module_name.startswith("_"): - return module_name + try: + caller_frame = sys._getframe(2) + except ValueError: + # Fewer than three frames — the old form's `len(stack) > 2` guard. + return "unknown" + + module_name = Path(caller_frame.f_code.co_filename).stem + if module_name and not module_name.startswith("_"): + return module_name return "unknown" @@ -212,7 +311,7 @@ def get_json_path(module_name: str, json_type: str) -> Path: Returns: Absolute :class:`~pathlib.Path` to the JSON file. """ - return JSON_DIR / f"{module_name}_{json_type}.json" + return _current_json_dir() / f"{module_name}_{json_type}.json" # --------------------------------------------------------------------------- @@ -232,7 +331,7 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: Returns: ``True`` after the file is confirmed present and valid. """ - JSON_DIR.mkdir(parents=True, exist_ok=True) + _current_json_dir().mkdir(parents=True, exist_ok=True) json_path = get_json_path(module_name, json_type) if json_path.exists(): @@ -478,7 +577,7 @@ def update_data_metrics(module_name: str, **metrics: Any) -> bool: ) console.print() console.print(f"[dim]Branch root:[/dim] {_BRANCH_ROOT}") - console.print(f"[dim]JSON dir:[/dim] {JSON_DIR}") + console.print(f"[dim]JSON dir:[/dim] {_current_json_dir()}") console.print() console.print("[yellow]TESTING:[/yellow] Creating drone JSONs...") diff --git a/src/aipass/drone/apps/handlers/module_registry_handler.py b/src/aipass/drone/apps/handlers/module_registry_handler.py index 6974ca092..f0d1752d3 100644 --- a/src/aipass/drone/apps/handlers/module_registry_handler.py +++ b/src/aipass/drone/apps/handlers/module_registry_handler.py @@ -26,6 +26,7 @@ from aipass.prax import logger from aipass.drone.apps.handlers.json import json_handler from aipass.drone.apps.handlers.generic_adapter import capture_main +from aipass.drone.apps.handlers.module_root import module_file # --------------------------------------------------------------------------- @@ -39,7 +40,7 @@ # --------------------------------------------------------------------------- # External modules — loaded from routing_config.json # --------------------------------------------------------------------------- -_ROUTING_CONFIG_PATH: Path = Path(__file__).resolve().parent / "routing_config.json" +_ROUTING_CONFIG_PATH: Path = module_file(__file__).parent / "routing_config.json" @dataclass diff --git a/src/aipass/drone/apps/handlers/module_root.py b/src/aipass/drone/apps/handlers/module_root.py new file mode 100644 index 000000000..1ef22ac71 --- /dev/null +++ b/src/aipass/drone/apps/handlers/module_root.py @@ -0,0 +1,99 @@ +# =================== AIPass ==================== +# Name: module_root.py +# Description: Resolve a module's __file__ without an import-time cwd read +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""One guarded spelling for module-level ``__file__`` resolution. + +``ntpath.realpath`` reads ``os.getcwd()`` UNCONDITIONALLY — not only for +relative paths, the way ``posixpath`` does — and ``Path.resolve()`` routes +through it (measured on the Windows CI gate 2026-08-31, @memory's finding). +So on Windows every module-level ``Path(__file__).resolve()`` is an +import-time working-directory dependency: a process whose cwd was deleted +cannot import the module at all, and no amount of guarding INSIDE the module's +functions helps, because the import died before any of them existed. + +Drone carried three such sites — ``json_handler``, ``command_registry.ops`` +and ``module_registry_handler`` — and ``json_handler`` is imported by nearly +every other module in the branch, so one line took the whole router down. +Measured under the hostile world rather than argued: 63 of 63 drone modules +died on import, 56 of them at ``json_handler.py:41`` once the handlers guard +above it was cured. + +Sized to this branch, following @memory's ratified ``repo_root.module_file``. +Drone has no module-level repo-root walk — ``TestTheSweepIsComplete`` has +banned bare working-directory reads outside ``caller_cwd()`` since session 77 — +so only the resolve guard lives here. + +WHY THIS IS NOT IN ``handlers/__init__.py``: the package guard runs before any +submodule exists and cannot import one without re-entering itself. It carries +its own two-line ``_safe_resolve`` on purpose. A guard that depends on the +thing it guards is not a guard. +""" + +from __future__ import annotations + +from pathlib import Path + +from aipass.prax import logger + +MODULE_NAME = "module_root" + + +def module_file(file: str) -> Path: + """A module's own path, absolute, resolved when the filesystem allows. + + ``.resolve()`` is still ATTEMPTED, because normalising symlinks is why the + call is there and it succeeds on every healthy machine. The fallback is + reached only in the world where the alternative is a dead import, and it is + sound: ``__file__`` has been absolute since Python 3.9, so the return names + the right file either way — just spelled through the symlink rather than + past it. + + Args: + file: A module's ``__file__``. + + Returns: + The module's path, resolved if the filesystem could be asked. + """ + path = Path(file) + try: + return path.resolve() + except OSError as exc: + # debug, not warning: in the world that reaches this line EVERY module + # import takes it, and three identical warnings describe one condition. + # Whichever lane actually fails on it is the one that should be loud. + logger.debug(f"[{MODULE_NAME}] Cannot resolve {path} ({type(exc).__name__}) — using its absolute spelling") + _record_unresolved(path, exc) + return path + + +def _record_unresolved(path: Path, exc: OSError) -> None: + """Record the fallback without ever raising. + + The import is deliberately function-level, and for TWO reasons. It breaks a + genuine cycle — ``json_handler`` is one of this module's three callers, so a + module-level import here would be drone importing itself mid-definition — + and it defers the cost to the only world that reaches this line. When the + caller IS ``json_handler``'s own line 41, ``sys.modules`` hands back a + half-built module and ``log_operation`` may not exist yet; that is an + ``AttributeError``, caught below, and losing one audit line is the right + trade against turning the import crash back on. + + Args: + path: The ``__file__`` that could not be resolved. + exc: The ``OSError`` ``resolve()`` raised. + """ + try: + from aipass.drone.apps.handlers.json import json_handler + + json_handler.log_operation( + "module_file_unresolved", + {"path": str(path), "error": f"{type(exc).__name__}: {exc}"}, + module_name=MODULE_NAME, + ) + except Exception as inner: # noqa: BLE001 - an audit line must never take an import down + logger.debug(f"[{MODULE_NAME}] fallback not recorded: {type(inner).__name__}: {inner}") diff --git a/src/aipass/drone/apps/handlers/registry_handler.py b/src/aipass/drone/apps/handlers/registry_handler.py index 7ce34be29..2cf986667 100644 --- a/src/aipass/drone/apps/handlers/registry_handler.py +++ b/src/aipass/drone/apps/handlers/registry_handler.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: registry_handler.py # Description: Handler for registry file operations -# Version: 1.0.0 +# Version: 1.2.1 # Created: 2026-03-09 -# Modified: 2026-03-09 +# Modified: 2026-08-31 # ============================================= """ @@ -26,6 +26,7 @@ RegistryPermissionError, ) from aipass.drone.apps.handlers.json import json_handler +from .router_handler import caller_cwd, registries_in # --------------------------------------------------------------------------- @@ -73,16 +74,36 @@ def _first_registry_in(directory: Path) -> Optional[Path]: When multiple matches exist, the alphabetically-first name wins so the result is deterministic across platforms. """ - matches = sorted(directory.glob("*_REGISTRY.json")) + matches = registries_in(directory) return matches[0] if matches else None def _registry_matches_credential(registry_path: Path) -> bool: """Check whether a candidate registry matches the nearest passport. - Returns True when the registry is acceptable (IDs match, or either - side is missing an ID). Returns False only when both IDs exist and - disagree — the caller should skip this registry and keep walking. + Returns True when the registry is acceptable (IDs match, either side is + missing an ID, or the caller has no directory to walk from). Returns False + only when both IDs exist and disagree — the caller should skip this registry + and keep walking. + + DECLARED ROOTS DO NOT COME THROUGH HERE, AND THAT IS DELIBERATE. + DECLARATION IS THE CREDENTIAL (@devpulse's ruling, FPLAN-0460 phase 3). + This gate asks an INTRA-installation question — "are you a citizen of the + registry you are standing in" — and a cross-repo answer is not available to + it, because the ids differ BY CONSTRUCTION: AIPASS 7087bb93, VERA-STUDIO + 8fb38c96, WREN 9d11c395. Routing every external root through this check + would refuse all of them, always, for being what they are. + + The authority a walk could never attach is Patrick blessing + AIPASS_ROOTS.json. That file is the credential, and @memory's reader is the + only thing that reads it. + + NOTE FOR WHOEVER FINDS THE UNCHECKED PATH LATER: the AIPASS_HOME fallback in + find_registry() has never been credential-checked either, and neither is the + external tier below. That is not an oversight to fix. "Fixing" it silently + kills every external citizen — the fence test (@wren) simply stops + resolving, with no error, because being refused is indistinguishable from + never having been declared. """ try: with open(registry_path, "r", encoding="utf-8") as f: @@ -91,7 +112,20 @@ def _registry_matches_credential(registry_path: Path) -> bool: if not registry_id: return True - cwd = Path.cwd() + cwd = caller_cwd() + if cwd is None: + # No location is not a failed check. The walk below infers from + # where the caller STANDS, and a process whose directory was + # deleted stands nowhere — the same answer as a walk that finds no + # passport, reached honestly instead of through the except below + # logging "pre-check failed" for a check that did not fail. + logger.info( + "Credential pre-check for %s has no current directory to walk from — " + "no passport is reachable, so the registry is accepted", + registry_path, + ) + return True + for parent in [cwd] + list(cwd.parents): candidate = parent / ".trinity" / "passport.json" if candidate.is_file(): @@ -121,14 +155,18 @@ def find_registry() -> Path: When a candidate registry's metadata.id conflicts with the nearest passport's registry_id, it is skipped and the walk continues upward. """ - # Walk up from cwd FIRST — this is where the user is working - cwd = Path.cwd() - for parent in [cwd] + list(cwd.parents): - hit = _first_registry_in(parent) - if hit is not None: - if _registry_matches_credential(hit): - return hit - continue + # Walk up from cwd FIRST — this is where the user is working. A caller + # whose directory was deleted has no "where", so step 3 is SKIPPED rather + # than attempted: the remaining sources (AIPASS_HOME, the package walk) + # never depended on a location and still answer. + cwd = caller_cwd() + if cwd is not None: + for parent in [cwd] + list(cwd.parents): + hit = _first_registry_in(parent) + if hit is not None: + if _registry_matches_credential(hit): + return hit + continue # AIPASS_HOME fallback — for external projects where CWD walk finds nothing aipass_home = os.environ.get("AIPASS_HOME") @@ -189,8 +227,19 @@ def _verify_registry_credential(registry_path: Path, registry_data: Dict[str, An if not registry_id: return - # Walk up from CWD looking for .trinity/passport.json - cwd = Path.cwd() + # Walk up from CWD looking for .trinity/passport.json. No location + # means no passport to compare against — the same silent pass as a walk + # that finds none, said at INFO instead of arriving as a warning about + # a verification that never failed. + cwd = caller_cwd() + if cwd is None: + logger.info( + "Registry credential verification for %s has no current directory to walk from — " + "no passport is reachable, so there is nothing to compare", + registry_path, + ) + return + passport_path = None for parent in [cwd] + list(cwd.parents): candidate = parent / ".trinity" / "passport.json" @@ -327,6 +376,108 @@ def load_registry() -> Dict[str, Any]: return data +def _external_branches(repo_root: Optional[Path] = None) -> List[Dict[str, Any]]: + """Citizens in declared roots, in DECLARATION ORDER, as registry entries. + + Consumes @memory's public gateway and never reads AIPASS_ROOTS.json — the + file has exactly one reader and it is theirs. A second reader here would be + the two-implementations failure the gateway exists to prevent, and it would + drift the first time their schema moved. + + Keyed the way local branches are: the registry's own ``name`` field, + lowercased. ``name_from="registry"`` is not a preference — local resolution + keys on that field (see _load_registry_data), and giving the external tier a + different rule would mean ``@wren`` resolving by one law and ``@memory`` by + another. + + Returns [] and says so LOUDLY when the gateway fails. An empty declared-roots + file is a legal state and silent; another branch's module raising is not. + """ + # Scoped to the project being resolved AGAINST, not to this checkout. + # get_all_branches(registry=X) must read X's declared roots, so an external + # project resolves through its own AIPASS_ROOTS.json and a caller that + # repoints the registry is not silently answered with ours. + # + # This is also the isolation seam the other two sources already have: the + # AIPASS_HOME source is switched off by unsetting its env var, and without + # an equivalent here the real machine's declared roots leaked into every + # enumeration test the moment Patrick blessed the file. A third source with + # no way to scope it is a third source that cannot be tested around. + if repo_root is None: + try: + repo_root = get_registry_path().parent + except Exception as exc: + logger.warning("External tier: cannot locate a project root to scope declared roots: %s", exc) + return [] + + # Imported HERE, not at module level, and the import sits INSIDE the guard. + # A module-level import made every failure in @memory's import chain a + # failure to import drone — router, `drone rm`, `drone systems`, all of it — + # which is the opposite of the containment this function's docstring + # promises. The live instance: registry_scope.py runs + # `REPO_ROOT = find_repo_root()` at module level and falls back to + # Path.cwd() when the walk up from __file__ finds no AIPASS_REGISTRY.json. + # A clean checkout has no registry (gitignored, machine-local), so CI takes + # that fallback, and a process whose directory was deleted raises ENOENT + # there. Theirs to fix; ours not to die of. + # + # sys.modules caches the module object, so this stays the SAME object the + # suite patches with patch.object(fleet, ...) — a call-time import is not a + # fresh one. + try: + from aipass.memory.apps.modules import fleet + + records = fleet.external_branches(repo_root, name_from="registry") + except Exception as exc: + logger.error( + "External tier unavailable — @memory's fleet gateway raised: %s. " + "Local and AIPASS_HOME resolution continue; declared-root citizens do not resolve.", + exc, + ) + return [] + + entries: List[Dict[str, Any]] = [] + for record in records: + name = str(record.get("name", "")).lower() + path = record.get("path") + registry_name = record.get("registry") + if not name or path is None or not registry_name: + logger.warning("Skipping malformed external record from the fleet gateway: %r", record) + continue + entries.append( + { + "name": name, + "path": str(path), + "email": record.get("email"), + "status": "active", + "residency": record.get("residency"), + "registry": registry_name, + } + ) + return entries + + +def _external_registry_path(entry: Dict[str, Any]) -> Optional[Path]: + """The sealed registry an external entry was read from. + + Derived by containment, never by walking up: the gateway hands back the + branch path and its registry FILENAME, and the declared root is the only + ancestor holding that file. + """ + branch_path = Path(entry["path"]) + for root in [branch_path, *branch_path.parents]: + candidate = root / entry["registry"] + if candidate.is_file(): + return candidate + logger.warning( + "External citizen '%s' names registry %s but it was not found above %s", + entry["name"], + entry["registry"], + entry["path"], + ) + return None + + def get_all_branches( branch_type: Optional[str] = None, status: str = "active", @@ -359,6 +510,26 @@ def get_all_branches( except (RegistryNotFoundError, RegistryCorruptError, RegistryPermissionError) as exc: logger.warning("get_all_branches: AIPass home registry unavailable: %s", exc) + # --- Declared roots (external tier) --- + # Last on purpose: AIPass local ALWAYS wins, and among externals the + # declaration order in roots[] breaks ties. Both are @devpulse's ruling. + # This is the one place both sides of a collision are visible at once, so it + # is where the collision is named — a shadowed citizen that vanishes without + # a line is indistinguishable from one that was never declared. + for entry in _external_branches(): + name = entry["name"] + if name in merged: + logger.warning( + "Name collision: '%s' is declared by external root %s (%s) and is SHADOWED by %s. " + "The external citizen is unreachable by name until one side renames.", + name, + entry["registry"], + entry["path"], + merged[name].get("path"), + ) + continue + merged[name] = entry + filtered = [] for branch in merged.values(): if status and branch.get("status") != status: @@ -394,10 +565,26 @@ def get_branch_by_name(name: str) -> Optional[Dict[str, Any]]: if home_path is not None and home_path != primary_path: try: home_data = _load_registry_data(home_path) - return home_data.get("branches", {}).get(lower_name) + branch = home_data.get("branches", {}).get(lower_name) + if branch is not None: + return branch + # A MISS here is not an answer. This used to return the lookup + # itself, so on any machine where AIPass home is not the project + # registry, an unknown name ended the search here and the declared + # roots below were unreachable. get_branch_with_registry has always + # guarded the identical lookup this way. except (RegistryNotFoundError, RegistryCorruptError, RegistryPermissionError) as exc: logger.warning("get_branch_by_name: AIPass home registry unavailable for '%s': %s", name, exc) + # --- Declared roots (external tier) --- + # Consulted only after both local sources miss, so a local citizen never + # pays a cross-repo read to resolve. First match wins, and the gateway + # returns declaration order, so the tiebreak is the file's own ordering. + for entry in _external_branches(): + if entry["name"] == lower_name: + logger.info("Resolved '%s' from declared root %s", lower_name, entry["registry"]) + return entry + return None @@ -430,4 +617,13 @@ def get_branch_with_registry(name: str) -> Optional[tuple]: except (RegistryNotFoundError, RegistryCorruptError, RegistryPermissionError) as exc: logger.warning("get_branch_with_registry: AIPass home registry unavailable for '%s': %s", name, exc) + # --- Declared roots (external tier) --- + for entry in _external_branches(): + if entry["name"] == lower_name: + external_registry = _external_registry_path(entry) + if external_registry is None: + continue + logger.info("Resolved '%s' from declared root %s", lower_name, external_registry) + return entry, external_registry + return None diff --git a/src/aipass/drone/apps/handlers/rm_handler.py b/src/aipass/drone/apps/handlers/rm_handler.py index 081db453b..399ef470c 100644 --- a/src/aipass/drone/apps/handlers/rm_handler.py +++ b/src/aipass/drone/apps/handlers/rm_handler.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: rm_handler.py # Description: Contained safe-delete handler -# Version: 1.1.0 +# Version: 1.2.1 # Created: 2026-06-02 -# Modified: 2026-06-02 +# Modified: 2026-08-31 # ============================================= """Contained safe-delete handler. @@ -27,21 +27,30 @@ from aipass.prax import logger from aipass.drone.apps.handlers import deletion_log +from aipass.drone.apps.handlers.router_handler import caller_cwd, registries_in from aipass.drone.apps.handlers.json import json_handler _CARVEOUT_DIRS = frozenset((".git", ".trinity", ".aipass", ".codex", ".agents")) def _find_project_root() -> Path | None: - """Walk up from CWD to find *_REGISTRY.json; return its parent as project root.""" - cwd = Path.cwd() - for parent in [cwd, *cwd.parents]: - if list(parent.glob("*_REGISTRY.json")): - return parent.resolve() + """Walk up from CWD to find *_REGISTRY.json; return its parent as project root. + + THE LIVE ONE. @trigger reproduced this: delete the directory you are standing + in, then run a second ``drone rm`` to clean up — the recovery command is the + one that crashed here, exit 1, before any of the delete lane's own guards ran. + The walk is skipped when there is nowhere to walk from; AIPASS_HOME answers + without a location and always could have. + """ + cwd = caller_cwd() + if cwd is not None: + for parent in [cwd, *cwd.parents]: + if registries_in(parent): + return parent.resolve() aipass_home = os.environ.get("AIPASS_HOME") if aipass_home: home = Path(aipass_home) - if home.is_dir() and list(home.glob("*_REGISTRY.json")): + if home.is_dir() and registries_in(home): return home.resolve() return None @@ -106,10 +115,17 @@ def _find_branch_root(path: Path, project_root: Path) -> Path | None: def _detect_current_branch(project_root: Path | None) -> str | None: - """Return the branch name the CWD lives in, or None.""" + """Return the branch name the CWD lives in, or None. + + Already Optional, and "no CWD" is one more way the answer is unknown — the + branch is inferred from where the caller stands, not from who they are. + """ if project_root is None: return None - branch_root = _find_branch_root(Path.cwd().resolve(), project_root) + cwd = caller_cwd() + if cwd is None: + return None + branch_root = _find_branch_root(cwd.resolve(), project_root) return branch_root.name if branch_root else None @@ -190,9 +206,28 @@ def _safe_delete_direct(paths: list[str]) -> list[tuple[str, bool, str]]: json_handler.log_operation("rm", {"paths": paths, "roots": [str(r) for r in roots]}) results: list[tuple[str, bool, str]] = [] + cwd = caller_cwd() for path_str in paths: original = Path(path_str) - absolute = original if original.is_absolute() else (Path.cwd() / original) + if original.is_absolute(): + absolute = original + elif cwd is not None: + absolute = cwd / original + else: + # A relative path names a place RELATIVE TO somewhere, and there is + # no somewhere. Refused per path rather than for the whole call, so + # `drone rm ./scratch /tmp/victim` still deletes what it can name. + message = f"Cannot resolve relative path '{path_str}' — this process has no current directory" + results.append((path_str, False, message)) + logger.info("rm: %s", message) + deletion_log.record_deletion( + lane=deletion_log.LANE_RM, + outcome=deletion_log.OUTCOME_REFUSED, + requested=path_str, + resolved=original, + reason=message, + ) + continue exists_on_disk = absolute.exists() or absolute.is_symlink() if not exists_on_disk: diff --git a/src/aipass/drone/apps/handlers/router_handler.py b/src/aipass/drone/apps/handlers/router_handler.py index d82b81c6d..a8311a25f 100644 --- a/src/aipass/drone/apps/handlers/router_handler.py +++ b/src/aipass/drone/apps/handlers/router_handler.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: router_handler.py # Description: Handler for command routing implementation -# Version: 1.1.0 +# Version: 1.2.0 # Created: 2026-03-09 -# Modified: 2026-08-08 +# Modified: 2026-08-31 # ============================================= """ @@ -102,6 +102,33 @@ def find_entry_point(branch_path: str, branch_name: str) -> Path: _REGISTRY_SUFFIX = "_REGISTRY.json" +_REGISTRY_GLOB = f"*{_REGISTRY_SUFFIX}" + + +def registries_in(directory: Path) -> List[Path]: + """Every ``*_REGISTRY.json`` in *directory*, exact-case, sorted. + + THE GLOB IS NOT THE FILTER. ``Path.glob`` asks the FILESYSTEM to match, and + on a case-insensitive one — Windows, and macOS by default — ``*_REGISTRY.json`` + also matches ``*_registry.json``. That is not hypothetical: this repository + ships ``drone_command_registry.json`` beside the drone package, plus + ``flow_json/fplan_registry.json`` and a ``.spawn/.template_registry.json`` in + every branch (pathlib's ``*`` matches dotfiles, unlike the ``glob`` module). + Windows CI caught it as a test red — ``find_registry()`` returned + ``src/aipass/drone/drone_command_registry.json`` — but the red was the + smaller half. A registry file is a project's TRUST ANCHOR: it decides which + installation a caller belongs to, which project name gets stamped on their + identity, and where the delete lane thinks the project root is. A plan-id + counter served in that role answers a question it was never asked. + + So the name is re-checked in Python, where ``str.endswith`` is case-sensitive + on every platform. Suffix only, never the stem: external projects name their + registry after themselves and nothing promises the stem is uppercase. + + One reader, called from every walk in this tree — the tenth private copy of + ``glob("*_REGISTRY.json")`` is how a fix lands on some of N identical paths. + """ + return sorted(p for p in directory.glob(_REGISTRY_GLOB) if p.name.endswith(_REGISTRY_SUFFIX)) def _project_name_from_registry(reg_file: Path) -> str | None: @@ -140,6 +167,30 @@ def _project_name_from_registry(reg_file: Path) -> str | None: return derived +def caller_cwd() -> Path | None: + """The caller's working directory, or None when the process has none. + + ``Path.cwd()`` raises ENOENT once the directory it names is gone, and a + caller standing in a directory that was just deleted is now an ordinary + state rather than a crash — the delete lane was fixed to survive it first, + which is exactly what put a live process here to route a second command. + + None is the absence of the location signal, not a failure to read it. Who a + process IS was never derived from where it stood (S102), so an assigned + identity still answers from here; only the inference from a passport under + the caller's feet has nothing left to read. + + Lives in this module because it is the caller-location signal and this + module owns caller identity — deletion_log imports it rather than keeping a + second copy, which is the direction the dependency already runs. + """ + try: + return Path.cwd() + except OSError as exc: + logger.info("No current directory — it was deleted out from under this process (%s)", exc) + return None + + def detect_caller_signal(cwd: Path) -> CallerSignal: """Walk up from cwd for a passport, then fall back to the registry project. @@ -186,9 +237,12 @@ def detect_caller_signal(cwd: Path) -> CallerSignal: # Fallback: detect project name from registry file (callers at a project root) current = cwd.resolve() for _ in range(10): - # sorted() so a directory holding two registries resolves the same way - # every time, matching registry_handler._first_registry_in. - for reg_file in sorted(current.glob(f"*{_REGISTRY_SUFFIX}")): + # registries_in() sorts, so a directory holding two registries resolves + # the same way every time, matching registry_handler._first_registry_in + # — and drops the case-insensitive filesystem's extra matches, which + # here would have stamped the caller with a project name derived from + # whatever lowercase *_registry.json the walk passed first. + for reg_file in registries_in(current): project_name = _project_name_from_registry(reg_file) if project_name: return CallerSignal(project_name, "project") @@ -221,7 +275,7 @@ def detect_caller_signal(cwd: Path) -> CallerSignal: return CallerSignal(None, None) -def resolve_caller_identity_signal(cwd: Path) -> CallerIdentity: +def resolve_caller_identity_signal(cwd: Path | None) -> CallerIdentity: """Resolve who is CALLING drone, WITH the provenance of the answer. Two signals can answer "who is calling", and they are not the same kind of @@ -256,7 +310,12 @@ def resolve_caller_identity_signal(cwd: Path) -> CallerIdentity: service in this system. """ assigned = os.environ.get("AIPASS_BRANCH_NAME") or None - standing, source = detect_caller_signal(cwd) + # None means the process has NO working directory — the caller deleted the + # one it was standing in. That is not a cwd we failed to read, it is the + # absence of the signal, and inferring from a directory that no longer + # exists would answer a question nobody can answer. The assigned identity + # still holds: who this process IS never depended on where it stood. + standing, source = detect_caller_signal(cwd) if cwd is not None else CallerSignal(None, None) if assigned and standing and assigned.lower() != standing.lower(): if source == "passport": @@ -299,7 +358,7 @@ def resolve_caller_identity_signal(cwd: Path) -> CallerIdentity: return CallerIdentity(None, None) -def resolve_caller_identity(cwd: Path) -> str | None: +def resolve_caller_identity(cwd: Path | None) -> str | None: """Resolve who is CALLING drone — the name alone. The bare-name view of :func:`resolve_caller_identity_signal`, kept because @@ -341,18 +400,21 @@ def execute_branch_command( if command: cmd_args += [command] + list(args or []) - # Pass caller's CWD so target branches can detect who invoked them - caller_env = { - "AIPASS_CALLER_CWD": str(Path.cwd()), - "AIPASS_BRANCH_NAME": branch_name, - } + # Pass caller's CWD so target branches can detect who invoked them. + # Omitted entirely when there is none, matching how the identity keys below + # handle their own absence: a target that reads this as a location must get + # no answer rather than a sentinel it might try to resolve. + cwd = caller_cwd() + caller_env = {"AIPASS_BRANCH_NAME": branch_name} + if cwd is not None: + caller_env["AIPASS_CALLER_CWD"] = str(cwd) # Who is calling: assigned identity first, cwd passport only as fallback. # The provenance ships WITH the name: a consumer deciding whether to trust # this identity cannot re-derive it — it is in another process, with a # different cwd and no access to our environment. Unstamped, "commons # standing in /tmp" and "nobody standing at a repo root" arrive identical. - caller_branch, identity_source = resolve_caller_identity_signal(Path.cwd()) + caller_branch, identity_source = resolve_caller_identity_signal(cwd) if caller_branch: caller_env["AIPASS_CALLER_BRANCH"] = caller_branch caller_env["AIPASS_CALLER_IDENTITY_SOURCE"] = identity_source or "unknown" diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index 2593ef7d5..d3ebf9b6e 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: git_module.py # Description: Git workflow module — PR, status, sync, lock management -# Version: 1.1.0 +# Version: 1.2.0 # Created: 2026-03-17 -# Modified: 2026-08-12 +# Modified: 2026-08-31 # ============================================= """ @@ -39,6 +39,7 @@ remote_handler, ) from aipass.drone.apps.handlers.help_flags import wants_help +from aipass.drone.apps.handlers.router_handler import caller_cwd from aipass.drone.apps.handlers.json_flags import strip_json_flag, wants_json DRONE_MODULE = { @@ -107,8 +108,15 @@ def _detect_branch_dir() -> tuple[str, Path] | None: Walks up from CWD looking for ``.trinity/passport.json`` and extracts the branch name + directory. Works for any registered branch regardless of where it lives on disk (commons, skills, aipass sub-dirs, etc.). + + Returns None when the process has no CWD — this detects the branch from + LOCATION, and there is nothing to detect from. Identity assigned at spawn + is a different question, answered elsewhere and unaffected. """ - current = Path.cwd().resolve() + cwd = caller_cwd() + if cwd is None: + return None + current = cwd.resolve() for _ in range(10): passport = current / ".trinity" / "passport.json" if passport.exists(): diff --git a/src/aipass/drone/apps/modules/router.py b/src/aipass/drone/apps/modules/router.py index de5ae4672..a7662a8b5 100644 --- a/src/aipass/drone/apps/modules/router.py +++ b/src/aipass/drone/apps/modules/router.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: router.py # Description: Command routing logic for the drone module -# Version: 1.0.1 +# Version: 1.0.2 # Created: 2026-03-09 -# Modified: 2026-08-11 +# Modified: 2026-08-31 # ============================================= """ @@ -14,7 +14,6 @@ """ import sys -from pathlib import Path from typing import Dict, List, Optional from aipass.prax.apps.modules.logger import system_logger @@ -23,6 +22,11 @@ from aipass.drone.apps.handlers.json import json_handler from aipass.drone.apps.handlers.router_handler import ( execute_branch_command, + caller_cwd, + # Re-exported, not used here: apps/drone.py reaches handlers through this + # module rather than importing them directly (the same route caller_cwd + # already takes), so the entry point keeps one seam into the handler layer. + registries_in, # noqa: F401 resolve_caller_identity, ) from .resolver import list_branches, resolve_branch @@ -115,7 +119,7 @@ def route_command( # Same resolver execute_branch_command uses — logging its own precedence # here would let the CALLER: tag name someone other than the branch actually # stamped on the work. - caller = resolve_caller_identity(Path.cwd()) + caller = resolve_caller_identity(caller_cwd()) # UNKNOWN, not an empty tag: an omitted caller reads as "not applicable" and # hides the gap. detect_caller_signal already logged the cwd. caller_tag = f" [CALLER:{caller.upper()}]" if caller else " [CALLER:UNKNOWN]" diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/auth.py b/src/aipass/drone/apps/plugins/devpulse_ops/auth.py index ccf60dc88..39ab2983e 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/auth.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/auth.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: auth.py # Description: Passport-based authorization for devpulse operations -# Version: 1.1.0 +# Version: 1.2.0 # Created: 2026-03-30 -# Modified: 2026-08-24 +# Modified: 2026-08-31 # ============================================= """Passport-based authorization for git operations. @@ -25,6 +25,7 @@ from aipass.prax import logger from aipass.drone.apps.handlers.json import json_handler from aipass.drone.apps.handlers.git.repo_context import AIPASS_REGISTRY_NAME +from aipass.drone.apps.modules.router import caller_cwd from aipass.drone.apps.modules.registry import ( RegistryMismatchError, get_registry_path, @@ -157,8 +158,26 @@ def _resolve_caller() -> Caller: """Walk up from CWD to find passport.json and return the caller's identity. Raises PermissionError if no readable, named passport is found. + + THE ONE SITE IN THIS SWEEP THAT FAILS CLOSED. Everywhere else "no current + directory" is the absence of a convenience and the answer is None. Here it + decides whether a caller may write to the repository, and an authority the + gate cannot establish is an authority the caller does not have — the same + answer it already gives when the walk finds no passport. What changed is + that it now arrives as a stated refusal instead of an ENOENT traceback out + of the middle of a credential check. """ - current = Path.cwd().resolve() + cwd = caller_cwd() + if cwd is None: + msg = ( + "This process has no current directory — it was deleted out from under it. " + "Caller identity is inferred by walking up from where you stand, so there is " + "nothing to verify against and owner-tier access is refused." + ) + logger.warning(msg) + raise PermissionError(msg) + + current = cwd.resolve() for _ in range(10): passport_path = current / ".trinity" / "passport.json" if passport_path.exists(): @@ -188,7 +207,7 @@ def _resolve_caller() -> Caller: # page anyone (@trigger log-fix 906263c8ff2e). The caller's cwd is the one # fact that identifies WHO tripped the gate; trigger's normalizer collapses # it to , so repeat signatures stay unified across callers. - msg = f"No .trinity/passport.json found in directory hierarchy (caller cwd: {Path.cwd()}) — cannot verify caller" + msg = f"No .trinity/passport.json found in directory hierarchy (caller cwd: {cwd}) — cannot verify caller" logger.warning(msg) raise PermissionError(msg) diff --git a/src/aipass/drone/tests/conftest.py b/src/aipass/drone/tests/conftest.py index 6c342f762..b38523e38 100644 --- a/src/aipass/drone/tests/conftest.py +++ b/src/aipass/drone/tests/conftest.py @@ -11,6 +11,7 @@ import json import logging import shutil +import sys from pathlib import Path from typing import Generator from unittest.mock import MagicMock @@ -183,3 +184,45 @@ def mock_json_handler() -> MagicMock: handler.validate_json_structure = MagicMock(return_value=True) handler.log_operation = MagicMock(return_value=True) return handler + + +# --------------------------------------------------------------------------- +# The deletable-cwd marker +# --------------------------------------------------------------------------- + +DELETABLE_CWD_MARKER = "deletable_cwd" + +WINDOWS_CWD_REASON = ( + "This test builds its world by deleting the directory the process stands in. " + "Windows holds the current directory open without delete sharing, so rmtree(cwd) " + "raises PermissionError WinError 32 and the directory is never removed — the RECIPE " + "is unavailable there, not the STATE. A disconnected share or an ejected volume still " + "leaves a live Windows process whose getcwd() raises, so the guards themselves stay " + "pinned on every OS by the patched-Path.cwd construction (tests/test_no_cwd_sweep.py " + "and the portable siblings beside each skipped test). What Windows loses is only the " + "end-to-end half: that a real deletion actually produces the state." +) + + +def pytest_configure(config): + """Register the marker here rather than in pytest.ini. + + The composed CI run loads this suite from the repository root with a + different inifile, so a marker declared in drone's own pytest.ini would be + unknown there — and --strict-markers turns an unknown marker into a + collection error. A conftest travels with the tests that use it. + """ + config.addinivalue_line( + "markers", + f"{DELETABLE_CWD_MARKER}: needs a process to delete the directory it stands in (POSIX only)", + ) + + +def pytest_collection_modifyitems(config, items): + """Skip deletable-cwd tests on Windows only, and say the whole reason.""" + if sys.platform != "win32": + return + skip = pytest.mark.skip(reason=WINDOWS_CWD_REASON) + for item in items: + if DELETABLE_CWD_MARKER in item.keywords: + item.add_marker(skip) diff --git a/src/aipass/drone/tests/test_bypass_anchors.py b/src/aipass/drone/tests/test_bypass_anchors.py new file mode 100644 index 000000000..3e50713c0 --- /dev/null +++ b/src/aipass/drone/tests/test_bypass_anchors.py @@ -0,0 +1,128 @@ +# =================== AIPass ==================== +# Name: test_bypass_anchors.py +# Description: A line-scoped standards waiver must still point at what it claims +# Version: 1.0.0 +# Created: 2026-08-31 +# ============================================= + +"""A waiver that fails OPEN and SILENT is the one failure mode a waiver must not have. + +THE SPECIES, @trigger's (round 5), measured fleet-wide by @seedgo (round 6). A +bypass entry scoped to LINE NUMBERS is a pointer into a file, and every edit above +those lines invalidates it silently. @trigger's round-4 sweep added comment lines +and shifted four of their waivers off target; the suppressed violations resurfaced +as unexplained findings in files nobody had touched semantically. Nothing reports +this — a waiver that stops matching simply stops working, and a waiver that starts +matching again suppresses whatever now occupies those lines. + +DRONE'S EXPOSURE, measured by @seedgo and reproduced here line by line before +acting on it: nine numbers across two ``cli`` entries, every one adrift (+8 and ++13 in ``apps/drone.py``, +4 in ``apps/modules/router.py``). Those two entries +were DELETED rather than re-derived, because they had also become redundant — +``cli_check`` learned the relayed-stream exemption in round two, so both files +score 100 on their own merits. Verified by A/B here: removing both left ``cli`` at +100 and violations at 0. The fleet rule decides it — if the exemption is +structurally detectable, the checker learns it; a waiver is only for what cannot +be measured. + +ONE LINE-SCOPED WAIVER SURVIVES, ``auth.py:27``, and it was on target. It stays +because it is a genuine cannot-be-measured case, and it now carries an ``anchor``: +the text that line must contain. This file is what makes the anchor mean anything. + +NOTHING BUT THIS FILE WATCHES THE ANCHOR. @trigger read the two match sites in +seedgo before relying on theirs — ``bypass_handler.py:223`` and +``bypass/utils.py:34`` both match on ``lines`` alone, so the ``anchor`` key is +inert to the checker and this test is the only thing enforcing it. Said out loud +because verifying the audit still scores 100 with the key present (which is what +this branch did first) proves seedgo does not CHOKE on the field, not that it +reads it — an absence of evidence standing in for knowledge. + +WHY AN ANCHOR RATHER THAN A RE-DERIVATION: re-deriving fixes today's numbers and +leaves tomorrow's edit free to break them the same way. @seedgo has the general +consequence queued for the checker pack — waivers should anchor to CONTENT, not to +line numbers — and this is that rule enforced locally in the one branch that still +has the exposure. If a future edit moves line 27, this file goes red naming the +entry, instead of the waiver going quiet. +""" + +import json +from pathlib import Path + +import pytest + +# No ``.resolve()`` here, and that is the point rather than an omission. The first +# cut of this line was a bare module-level ``Path(__file__).resolve()`` — the exact +# import-time defect cured across apps/ this morning, written again hours later in +# a new file, and the AST ban only covers apps/ so nothing would have said so. +# Wrapping it in try/except was the second wrong answer (a silent catch seedgo +# caught). ``__file__`` is absolute, so the raw spelling already names the right +# directory; the resolve was only ever normalising symlinks nobody asked about. +BRANCH_ROOT = Path(__file__).parents[1] +BYPASS_FILE = BRANCH_ROOT / ".seedgo" / "bypass.json" + + +def _line_scoped_entries() -> list[dict]: + data = json.loads(BYPASS_FILE.read_text(encoding="utf-8")) + return [entry for entry in data["bypass"] if entry.get("lines")] + + +class TestLineScopedBypassesAreOnTarget: + def test_the_collector_finds_every_line_scoped_entry_the_file_holds(self): + """The instrument's own arming probe, and its first cut was not enough. + + Every assertion below is PARAMETRIZED over ``_line_scoped_entries()``, and + pytest reports a parametrized test with an empty list as SKIPPED. So a + collector that returns nothing produces "1 passed, 2 skipped" — a green + summary line for an instrument that checked nothing. Measured, not feared: + M20 blinded the collector to ``return []`` and the first version of this + probe SURVIVED it, because all it asserted was that the raw ``bypass`` + list was non-empty. That is the same species as drone's round-3 finding + about narrow skips: a check that reports its own defeat as a pass. + + The recount is deliberately INDEPENDENT of the collector — it walks the + raw JSON rather than calling the function it is judging. Asking the + accused is how a guard deletes the failures it exists to expose. + """ + data = json.loads(BYPASS_FILE.read_text(encoding="utf-8")) + assert data["bypass"], "no bypass entries at all — the file or its schema changed" + + expected = [entry for entry in data["bypass"] if "lines" in entry] + assert expected, ( + "no line-scoped entries found in the raw file — if the last one was " + "deleted, delete this file too rather than leaving it green over nothing" + ) + assert len(_line_scoped_entries()) == len(expected), ( + f"the collector found {len(_line_scoped_entries())} line-scoped entries " + f"where the file holds {len(expected)} — every parametrized check below " + "is silently skipping the difference" + ) + + @pytest.mark.parametrize("entry", _line_scoped_entries(), ids=lambda e: f"{e['file']}:{e['standard']}") + def test_every_line_scoped_entry_declares_an_anchor(self, entry): + """A waiver that cannot say what it points at cannot be trusted to still point there.""" + assert entry.get("anchor"), ( + f"{entry['file']} [{entry['standard']}] is scoped to lines {entry['lines']} with no anchor — " + "add the text those lines must contain, so drift is caught instead of silently fixing itself" + ) + + @pytest.mark.parametrize("entry", _line_scoped_entries(), ids=lambda e: f"{e['file']}:{e['standard']}") + def test_every_line_scoped_entry_still_points_at_its_anchor(self, entry): + """The pin itself: the numbered line must contain the text the entry claims.""" + source = BRANCH_ROOT / entry["file"] + assert source.is_file(), f"{entry['file']} named by a bypass entry does not exist" + + lines = source.read_text(encoding="utf-8").splitlines() + anchor = entry["anchor"] + + for number in entry["lines"]: + assert 1 <= number <= len(lines), ( + f"{entry['file']} [{entry['standard']}] bypasses line {number}, but the file has {len(lines)} lines" + ) + actual = lines[number - 1] + assert anchor in actual, ( + f"{entry['file']}:{number} [{entry['standard']}] has DRIFTED.\n" + f" expected to contain: {anchor}\n" + f" actual line: {actual.strip()!r}\n" + "A line-scoped waiver whose number moves fails open and silent — re-derive it, " + "or delete it if the checker has since learned the exemption." + ) diff --git a/src/aipass/drone/tests/test_caller_identity_provenance.py b/src/aipass/drone/tests/test_caller_identity_provenance.py index 40944d689..8960b0266 100644 --- a/src/aipass/drone/tests/test_caller_identity_provenance.py +++ b/src/aipass/drone/tests/test_caller_identity_provenance.py @@ -25,6 +25,7 @@ """ import json +import shutil from pathlib import Path import pytest @@ -184,3 +185,197 @@ def _capture(**kwargs): env = captured["env"] assert "AIPASS_CALLER_BRANCH" not in env assert "AIPASS_CALLER_IDENTITY_SOURCE" not in env + + +# --------------------------------------------------------------------------- +# Routing must survive a caller whose directory no longer exists +# --------------------------------------------------------------------------- + + +@pytest.mark.deletable_cwd +class TestRoutingFromADeletedDirectory: + """``drone rm`` on your own cwd now succeeds — so the NEXT command runs here. + + Every routed invocation reads the caller's cwd twice: once to ship + AIPASS_CALLER_CWD to the target, once to resolve who is calling. Both reads + were bare, and a process whose directory was deleted raises ENOENT on each. + + The delete lane was fixed first, which is what made this reachable rather + than theoretical: before that fix the delete itself crashed, so nobody ever + got to type a second command from a directory that was gone. @trigger found + the count was three reads, not the two this branch first reported. + """ + + def test_routing_does_not_crash_when_the_cwd_was_deleted(self, tmp_path, monkeypatch): + from unittest.mock import MagicMock, patch + + from aipass.drone.apps.handlers import router_handler + + doomed = tmp_path / "scratch" + doomed.mkdir() + monkeypatch.chdir(doomed) + shutil.rmtree(doomed) + + captured = {} + + def _capture(**kwargs): + captured.update(kwargs) + return MagicMock(stdout="", stderr="", exit_code=0) + + with ( + patch.object(router_handler, "find_entry_point", return_value=Path("/tmp/branch/apps/x.py")), + patch.object(router_handler, "execute_command", side_effect=_capture), + ): + router_handler.execute_branch_command(branch_path="/tmp/branch", branch_name="x", command="ping") + + assert captured, "routing died on a cwd that no longer exists" + + def test_the_absent_cwd_is_not_forwarded_as_a_real_path(self, tmp_path, monkeypatch): + """The target reads AIPASS_CALLER_CWD as a location. It must not get a lie.""" + from unittest.mock import MagicMock, patch + + from aipass.drone.apps.handlers import router_handler + + doomed = tmp_path / "scratch" + doomed.mkdir() + monkeypatch.chdir(doomed) + shutil.rmtree(doomed) + + captured = {} + + def _capture(**kwargs): + captured.update(kwargs) + return MagicMock(stdout="", stderr="", exit_code=0) + + with ( + patch.object(router_handler, "find_entry_point", return_value=Path("/tmp/branch/apps/x.py")), + patch.object(router_handler, "execute_command", side_effect=_capture), + ): + router_handler.execute_branch_command(branch_path="/tmp/branch", branch_name="x", command="ping") + + assert captured["env"].get("AIPASS_CALLER_CWD") != str(doomed) + + def test_assigned_identity_still_answers_without_a_cwd(self, tmp_path, monkeypatch): + """Who this process IS never depended on where it was standing (S102).""" + from aipass.drone.apps.handlers import router_handler + + doomed = tmp_path / "scratch" + doomed.mkdir() + monkeypatch.chdir(doomed) + monkeypatch.setenv("AIPASS_BRANCH_NAME", "commons") + shutil.rmtree(doomed) + + identity = router_handler.resolve_caller_identity_signal(router_handler.caller_cwd()) + + assert identity.name == "commons" + assert identity.source == "assigned" + + def test_the_handlers_package_imports_at_all_without_a_cwd(self, tmp_path): + """Every guard above is unreachable if the IMPORT raises first. + + ``handlers/__init__.py`` runs a stack walk at import to block + cross-branch imports, and it called ``Path(filename).resolve()`` on each + frame BEFORE the line that skips Python's internals. ``resolve()`` on a + relative name — ```` is in every import + stack — goes through ``abspath``, which reads the current directory, so + importing drone's handlers from a deleted directory raised ENOENT + before any of this branch's cwd guards existed to run. + + A subprocess with a deleted cwd, not a mock: ``Path.resolve()`` reaches + ``os.getcwd`` inside C, so patching ``Path.cwd`` reproduces nothing. + """ + import subprocess + import sys + import textwrap + + doomed = tmp_path / "scratch" + doomed.mkdir() + probe = textwrap.dedent( + """ + import os, shutil, sys + here = sys.argv[1] + os.chdir(here) + shutil.rmtree(here) + from aipass.drone.apps.handlers import registry_handler + print("imported", registry_handler.__name__.rsplit(".", 1)[-1]) + """ + ) + result = subprocess.run( + [sys.executable, "-c", probe, str(doomed)], + capture_output=True, + text=True, + cwd=str(tmp_path), + ) + + assert result.returncode == 0, result.stderr + assert "imported registry_handler" in result.stdout + + +class TestRoutingWithoutACwdOnEveryOS: + """The same three claims as the class above, built portably. + + Those tests delete the directory they stand in, which Windows refuses (see + WINDOWS_CWD_REASON in conftest), so on Windows they are skipped and this is + the only cover routing's cwd guards have there. + + Supplied state, not produced: ``Path.cwd`` raises the ENOENT it raises for + real. The fourth claim of that class — that the handlers package IMPORTS at + all from a dead directory — has no portable sibling and is not given a fake + one. Its own docstring says why: the import-time walk reaches ``os.getcwd`` + inside C via ``Path.resolve()``, where patching ``Path.cwd`` reproduces + nothing. On Windows that claim is genuinely untested, and saying so is + better than a test that would pass without exercising it. + """ + + @pytest.fixture() + def no_cwd(self, monkeypatch): + def gone(): + raise FileNotFoundError(2, "No such file or directory") + + monkeypatch.setattr(Path, "cwd", staticmethod(gone)) + yield + + @staticmethod + def _route(tmp_path): + """Route one command with execution stubbed; return the captured kwargs.""" + from unittest.mock import MagicMock, patch + + from aipass.drone.apps.handlers import router_handler + + branch = tmp_path / "branch" + captured = {} + + def _capture(**kwargs): + captured.update(kwargs) + return MagicMock(stdout="", stderr="", exit_code=0) + + with ( + patch.object(router_handler, "find_entry_point", return_value=branch / "apps" / "x.py"), + patch.object(router_handler, "execute_command", side_effect=_capture), + ): + router_handler.execute_branch_command(branch_path=str(branch), branch_name="x", command="ping") + return captured + + def test_routing_does_not_crash(self, no_cwd, tmp_path): + assert self._route(tmp_path), "routing died with no current directory" + + def test_the_absent_cwd_is_omitted_rather_than_faked(self, no_cwd, tmp_path): + """The target reads AIPASS_CALLER_CWD as a location. It must not get a lie. + + Omitted, not blanked: an empty string is a value the target would try to + resolve, and a sentinel is a path somebody eventually treats as one. + """ + captured = self._route(tmp_path) + + assert "AIPASS_CALLER_CWD" not in captured["env"] + + def test_assigned_identity_still_answers(self, no_cwd, monkeypatch): + """Who this process IS never depended on where it was standing (S102).""" + from aipass.drone.apps.handlers import router_handler + + monkeypatch.setenv("AIPASS_BRANCH_NAME", "commons") + + identity = router_handler.resolve_caller_identity_signal(router_handler.caller_cwd()) + + assert identity.name == "commons" + assert identity.source == "assigned" diff --git a/src/aipass/drone/tests/test_deletion_log.py b/src/aipass/drone/tests/test_deletion_log.py index 6adc38514..dbc97542d 100644 --- a/src/aipass/drone/tests/test_deletion_log.py +++ b/src/aipass/drone/tests/test_deletion_log.py @@ -10,6 +10,8 @@ """ import json +import shutil +import tempfile from pathlib import Path from unittest.mock import patch @@ -357,8 +359,19 @@ def test_env_override_wins(self, project, monkeypatch, tmp_path): assert deletion_log.deletion_log_path() == elsewhere def test_override_cannot_silence_the_prax_line(self, project, monkeypatch): - """Relocating the store must not become a way to erase the event.""" - monkeypatch.setenv("AIPASS_DELETION_LOG", "/proc/nonexistent/d.jsonl") + """Relocating the store must not become a way to erase the event. + + The unwritable target is a FILE standing where the parent directory has + to be, so ``_append_record``'s ``mkdir(parents=True)`` raises inside the + tmp project. It used to be ``/proc/nonexistent``, which raised the same + OSError family but reached OUTSIDE the tree under test: the two os.mkdir + attempts were this suite's last outside-copy records in @seedgo's + hygiene gate. An unwritable path does not have to be someone else's + filesystem to be unwritable. + """ + blocked = project / "not_a_directory" + blocked.write_text("a file where a directory would have to be") + monkeypatch.setenv("AIPASS_DELETION_LOG", str(blocked / "d.jsonl")) target = project / "x.txt" target.write_text("x") @@ -393,3 +406,178 @@ def test_store_failure_is_reported_not_swallowed(self, project): safe_delete([str(target)]) assert log.error.called, "a lost deletion record must be loud" + + @pytest.mark.deletable_cwd + def test_deleting_the_current_directory_still_reports_success(self, project, monkeypatch): + """The delete worked. Losing cwd must not turn it into a failure. + + ``drone rm`` on the directory you are standing in is an ordinary thing + to do with a scratch dir. The rmtree succeeds and the process is left + with no cwd — every later ``Path.cwd()`` raises ENOENT. That is the + record's problem to absorb, not a result the caller should see. + """ + doomed = project / "scratch" + doomed.mkdir() + (doomed / "f.txt").write_text("x") + monkeypatch.chdir(doomed) + + results = safe_delete([str(doomed)]) + + assert results[0][1] is True, "the delete succeeded; the result must say so" + assert not doomed.exists() + + @pytest.mark.deletable_cwd + def test_deleting_the_current_directory_does_not_raise(self, project, monkeypatch): + """safe_delete returns per-path results. It must not become a crash. + + The failure this pins was worse than a wrong result: the record raised + inside the success path, the except handler recorded the failure, and + the SECOND record raised the same way — straight out of safe_delete, + past every caller expecting a list. + """ + doomed = project / "scratch" + doomed.mkdir() + monkeypatch.chdir(doomed) + + results = safe_delete([str(doomed)]) + + assert isinstance(results, list) + assert len(results) == 1 + + @pytest.mark.deletable_cwd + def test_record_deletion_does_not_raise_when_cwd_is_gone(self, project, monkeypatch): + """The docstring says 'Never raises'. Two lines read cwd outside its try.""" + doomed = project / "scratch" + doomed.mkdir() + monkeypatch.chdir(doomed) + shutil.rmtree(doomed) + + record = deletion_log.record_deletion( + lane=deletion_log.LANE_RM, + outcome=deletion_log.OUTCOME_DELETED, + requested=str(doomed), + resolved=doomed, + reason="Deleted", + ) + + assert record["outcome"] == deletion_log.OUTCOME_DELETED + + @pytest.mark.deletable_cwd + def test_a_successful_delete_is_never_logged_as_a_failure(self, project, monkeypatch): + """The log said 'deleted' and 'delete failed' for one path, one second. + + Both lines were true of what the code did and only one was true of + what happened. Whoever reads the log next must not have to guess which. + """ + doomed = project / "scratch" + doomed.mkdir() + monkeypatch.chdir(doomed) + + with patch("aipass.drone.apps.handlers.rm_handler.logger") as log: + safe_delete([str(doomed)]) + + failures = [c for c in log.error.call_args_list if "delete failed" in str(c)] + assert not failures, f"a delete that succeeded was reported as failed: {failures}" + + @pytest.mark.deletable_cwd + def test_the_record_still_lands_when_the_cwd_is_deleted(self, project, monkeypatch): + """Losing cwd must not cost the durable half of the record. + + The store path is derived by walking up from cwd, so the same ENOENT + that broke the caller's result also broke the walk — and the record of + the one delete most worth finding later fell to the prax line alone. + The env override and the tempdir home were both sitting right there, + unreachable because the walk raised before either was consulted. + """ + # tempfile caches gettempdir() on first use, so setting TMPDIR here + # would silently keep the real /tmp — and the test would write its + # record outside the tree it was given. Patch the resolved value. + home = project / "tmphome" + home.mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(home)) + monkeypatch.delenv("AIPASS_DELETION_LOG", raising=False) + doomed = project / "scratch" + doomed.mkdir() + monkeypatch.chdir(doomed) + + safe_delete([str(doomed)]) + + # No cwd to walk up from and no AIPASS_HOME, so the tempdir home the + # docstring already promises is the only place left that can answer. + store = home / "deletions.jsonl" + assert store.exists(), "the deletion record was lost with the directory" + record = json.loads(store.read_text().strip().splitlines()[-1]) + assert record["outcome"] == deletion_log.OUTCOME_DELETED + assert record["cwd"] == deletion_log.NO_CURRENT_DIRECTORY + + +# --------------------------------------------------------------------------- +# The same claims, on every OS +# --------------------------------------------------------------------------- + + +class TestTheRecordSurvivesAnAbsentCwdOnEveryOS: + """What the deletable-cwd tests above claim, built the portable way. + + Those tests reach the no-cwd state by deleting the directory they stand in, + which Windows refuses (see WINDOWS_CWD_REASON in conftest) — so on Windows + they are skipped and these are the only cover the record's guards have. + + The state here is supplied rather than produced: ``Path.cwd`` raises the + ENOENT it raises for real. That is weaker in exactly one way, stated so + nobody has to rediscover it — it cannot catch a read that reaches + ``os.getcwd`` inside C, the way ``Path.resolve()`` does. It is also stronger + in one way: it runs on all three operating systems. + """ + + @pytest.fixture() + def no_cwd(self, monkeypatch): + def gone(): + raise FileNotFoundError(2, "No such file or directory") + + monkeypatch.setattr(Path, "cwd", staticmethod(gone)) + yield + + def test_record_deletion_does_not_raise(self, no_cwd, tmp_path, monkeypatch): + """The docstring says "never raises". Two lines read cwd outside its try.""" + monkeypatch.delenv("AIPASS_HOME", raising=False) + + record = deletion_log.record_deletion( + lane=deletion_log.LANE_RM, + outcome=deletion_log.OUTCOME_DELETED, + requested=str(tmp_path / "scratch"), + resolved=tmp_path / "scratch", + reason="Deleted", + ) + + assert record["outcome"] == deletion_log.OUTCOME_DELETED + assert record["cwd"] == deletion_log.NO_CURRENT_DIRECTORY + + def test_the_record_still_lands_somewhere_durable(self, no_cwd, tmp_path, monkeypatch): + """Losing the cwd must not cost the durable half of the record. + + The store path is derived by walking up from cwd, so the same absence + that broke the caller's result also broke the walk — and the record of + the one delete most worth finding later fell to the prax line alone. + """ + home = tmp_path / "tmphome" + home.mkdir() + # tempfile caches gettempdir() on first use, so setting TMPDIR here + # would silently keep the real one. Patch the resolved value. + monkeypatch.setattr(tempfile, "tempdir", str(home)) + monkeypatch.delenv("AIPASS_DELETION_LOG", raising=False) + monkeypatch.delenv("AIPASS_HOME", raising=False) + + deletion_log.record_deletion( + lane=deletion_log.LANE_RM, + outcome=deletion_log.OUTCOME_DELETED, + requested=str(tmp_path / "scratch"), + resolved=tmp_path / "scratch", + reason="Deleted", + ) + + store = home / "deletions.jsonl" + assert store.exists(), "the deletion record had nowhere left to land" + record = json.loads(store.read_text(encoding="utf-8").strip().splitlines()[-1]) + assert record["outcome"] == deletion_log.OUTCOME_DELETED + assert record["cwd"] == deletion_log.NO_CURRENT_DIRECTORY diff --git a/src/aipass/drone/tests/test_external_roots.py b/src/aipass/drone/tests/test_external_roots.py new file mode 100644 index 000000000..f654ba523 --- /dev/null +++ b/src/aipass/drone/tests/test_external_roots.py @@ -0,0 +1,577 @@ +# =================== AIPass ==================== +# Name: test_external_roots.py +# Description: Declared roots are the third resolution source +# Version: 1.0.0 +# Created: 2026-08-30 +# ============================================= + +"""Citizens in declared roots resolve, and never at a local citizen's expense. + +FPLAN-0460 phase 3. @memory owns AIPASS_ROOTS.json and is its ONLY reader; this +branch consumes the records their gateway returns and decides precedence. + +Two rulings from @devpulse are pinned here rather than described: + + DECLARATION IS THE CREDENTIAL — an external registry can never satisfy the + metadata.id check, because the ids differ by construction. The gate asks an + intra-installation question and a cross-repo answer is not available to it. + + PRECEDENCE — AIPass local always wins; declaration order breaks ties among + externals; collisions are logged on both sides rather than resolved quietly. +""" + +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from aipass.memory.apps.handlers.monitor import registry_scope +from aipass.memory.apps.modules import fleet +from aipass.drone.apps.handlers import registry_handler + + +# --------------------------------------------------------------------------- +# Fixtures — stand-in roots, never a write into a sibling repo +# --------------------------------------------------------------------------- + + +def _make_root(base: Path, repo: str, branches: list[tuple[str, str]]) -> Path: + """Build a stand-in external repo with its own sealed registry.""" + root = base / repo + root.mkdir(parents=True) + rows = [] + for name, rel in branches: + branch = root / rel + (branch / ".trinity").mkdir(parents=True) + (branch / ".trinity" / "passport.json").write_text("{}") + rows.append({"name": name, "path": rel, "email": f"@{name.lower()}", "status": "active"}) + (root / f"{repo.upper()}_REGISTRY.json").write_text( + json.dumps({"metadata": {"id": f"{repo}-id"}, "branches": rows}) + ) + return root + + +@pytest.fixture() +def local_world(tmp_path, monkeypatch): + """A complete stand-in AIPass installation — registry, citizens, passport. + + CI has no ``AIPASS_REGISTRY.json``. It is gitignored and machine-local, so a + clean checkout has neither it nor ``AIPASS_ROOTS.json``. Four tests below + used the REAL machine's registry as their "local wins" baseline: green on + every developer box, red on every bare runner, with the failure reading + ``primary registry unavailable`` rather than anything about precedence. + + The hermeticity pass that preceded this one pinned both states of the ROOTS + file and assumed the REGISTRY existed. Half a world is not a world. What + these tests pin is precedence BETWEEN sources, which is a rule about the + resolver and not a fact about this machine — so the local source is now + built rather than borrowed. + + The world is self-consistent on purpose: ``metadata.id`` matches the + passport ``registry_id`` and ``caller_cwd`` points inside it, so the + credential gate passes on MERIT. A stand-in that simply omitted the id would + pass by being unverifiable, which is a different reason with the same colour. + """ + world = tmp_path / "StandInAIPass" + registry_id = "standin-registry-id" + for name in ("drone", "memory"): + trinity = world / "src" / name / ".trinity" + trinity.mkdir(parents=True) + (trinity / "passport.json").write_text(json.dumps({"citizenship": {"registry_id": registry_id}})) + (world / "AIPASS_REGISTRY.json").write_text( + json.dumps( + { + "metadata": {"id": registry_id}, + "branches": [ + {"name": "drone", "path": "src/drone", "email": "@drone", "status": "active"}, + {"name": "memory", "path": "src/memory", "email": "@memory", "status": "active"}, + ], + } + ) + ) + monkeypatch.delenv("AIPASS_HOME", raising=False) + monkeypatch.delenv("AIPASS_REGISTRY", raising=False) + monkeypatch.setattr(registry_handler, "caller_cwd", lambda: world / "src" / "drone") + registry_handler.set_registry_path(world / "AIPASS_REGISTRY.json") + yield world + registry_handler.reset_registry_path() + + +@pytest.fixture() +def standin(tmp_path): + """An AIPass home with declared roots, read by @memory's REAL reader.""" + home = tmp_path / "AIPassHome" + home.mkdir() + (home / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {"id": "home-id"}, "branches": []})) + _make_root(tmp_path, "alpha", [("ALPHA_AGENT", "src/alpha/worker")]) + _make_root(tmp_path, "beta", [("BETA_AGENT", "src/beta/helper")]) + (home / "AIPASS_ROOTS.json").write_text( + json.dumps( + { + "metadata": {"version": "1.0.0"}, + "roots": [ + {"path": "../alpha", "label": "alpha", "status": "active"}, + {"path": "../beta", "label": "beta", "status": "active"}, + ], + } + ) + ) + return home + + +# --------------------------------------------------------------------------- +# The unblessed file — today's behaviour, pinned +# --------------------------------------------------------------------------- + + +class TestTheStandInWorldIsSealed: + """The fixture's isolation is a test, not a hope. + + ``local_world`` unsets AIPASS_HOME because that is the SECOND resolution + source and a stand-in world that still answers through the real machine is + not a stand-in. Every other test here hits the primary registry first, so + dropping the ``delenv`` changed no result anywhere and the guard survived + mutation — an isolation nobody can observe is an isolation that quietly + stops working. + + Observable now: a citizen of the REAL installation, absent from the + stand-in, must not resolve inside it. On a bare runner AIPASS_HOME is unset + and this passes for the trivial reason; on a developer machine it passes + only because the fixture sealed the world. Both are correct answers to the + same question, which is what makes it safe to run in both. + """ + + def test_a_real_citizen_absent_from_the_stand_in_does_not_resolve(self, local_world): + with patch.object(fleet, "external_branches", return_value=[]): + assert registry_handler.get_branch_by_name("trigger") is None + + +class TestNothingMovesUntilPatrickBlesses: + """The tier is driven by the declared file and by nothing else. + + This class first pinned "declared_roots() is empty on this machine", which + was true for about twenty minutes. AIPASS_ROOTS.json was blessed mid-build + and the pin went red — correctly, because it asserted a machine STATE rather + than a rule. A test that a file has not been created yet expires the moment + someone creates it. What is durable is that a project with no declared roots + resolves exactly as it did before the tier existed. + """ + + def test_a_project_with_no_declared_roots_sees_no_externals(self, tmp_path): + """The empty state is legal and silent — not an error, not a fallback.""" + assert registry_handler._external_branches(repo_root=tmp_path) == [] + + def test_no_external_source_is_consulted_when_nothing_is_declared(self, local_world): + with patch.object(fleet, "external_branches", return_value=[]) as gateway: + assert registry_handler.get_branch_by_name("drone") is not None + assert not gateway.called, "a local hit must never pay for a cross-repo read" + + def test_a_local_citizen_still_resolves_unchanged(self, local_world): + branch = registry_handler.get_branch_by_name("memory") + assert branch is not None + assert branch["name"] == "memory" + assert branch["path"] == str(local_world / "src" / "memory") + + def test_an_unknown_name_is_still_not_found(self, local_world): + with patch.object(fleet, "external_branches", return_value=[]): + assert registry_handler.get_branch_by_name("nosuchbranch") is None + + +# --------------------------------------------------------------------------- +# The third source +# --------------------------------------------------------------------------- + + +class TestDeclaredRootsResolve: + def test_an_external_citizen_resolves_through_memorys_real_reader(self, standin): + """End to end against the gateway, not against a mock of it.""" + with patch.object( + fleet, + "external_branches", + side_effect=lambda _root=None, **kw: registry_scope.external_branches(standin, **kw), + ): + result = registry_handler.get_branch_with_registry("alpha_agent") + + assert result is not None, "a declared root's citizen did not resolve" + branch, registry_path = result + assert branch["name"] == "alpha_agent" + assert registry_path.name == "ALPHA_REGISTRY.json" + + def test_the_external_path_is_absolute_and_inside_its_own_root(self, standin): + with patch.object( + fleet, + "external_branches", + side_effect=lambda _root=None, **kw: registry_scope.external_branches(standin, **kw), + ): + found = registry_handler.get_branch_with_registry("beta_agent") + + assert found is not None, "the declared root's citizen did not resolve at all" + branch, registry_path = found + path = Path(branch["path"]) + assert path.is_absolute() + assert registry_path.parent in path.parents + + def test_externals_appear_in_the_enumeration(self, standin): + with patch.object( + fleet, + "external_branches", + side_effect=lambda _root=None, **kw: registry_scope.external_branches(standin, **kw), + ): + names = {b["name"] for b in registry_handler.get_all_branches()} + + assert {"alpha_agent", "beta_agent"} <= names + + +# --------------------------------------------------------------------------- +# Precedence — @devpulse's ruling +# --------------------------------------------------------------------------- + + +# A path these records merely CARRY — never resolved, never touched. Named once +# so the four malformed cases below say "the path is not what is wrong here". +_SOME_PATH = Path(tempfile.gettempdir()) / "x" / "src" / "a" + + +def _external(name: str, root: str) -> dict: + # tempfile.gettempdir() rather than a "/tmp" literal: Windows CI runs this + # suite and has no /tmp. The path is never touched on disk — only matched by + # substring — so the literal broke nothing, which is exactly why it survived + # two sessions in a file that has been red on Windows the whole time. + return { + "name": name, + "path": Path(tempfile.gettempdir()) / root / "src" / name, + "registry": f"{root.upper()}_REGISTRY.json", + "email": f"@{name}", + "residency": fleet.RESIDENCY_EXTERNAL, + } + # Five keys, matching registry_scope 4.0.0 exactly. `scheduler` was here + # until @memory dropped it: it reported ONE filename while @daemon reads + # .daemon/*.json, so a consumer pre-filtering on it would silently lose + # every job in a differently-named file. A stand-in more generous than the + # real reader is how a guard stops being observable — the same species as + # the name_from mutant that survived because this fixture once gave a + # directory and its registry row the same name. + + +class TestPrecedence: + def test_aipass_local_always_wins(self, local_world): + """An external named 'memory' must never shadow the local @memory. + + Stated as "the impostor changes nothing", not as "the answer lives under + this file's tree". The first cut asked the second question and it failed + the moment the suite ran anywhere but here: @seedgo's hygiene gate runs + it from an rsync'd copy in /tmp, where resolution answers correctly + through AIPASS_HOME with a path in the REAL tree and the proxy assertion + called that a loss. The impostor never won in either run — only the + yardstick moved. + + With a built world the yardstick is nailed down, so the stronger + assertion is available again and made: the answer is the stand-in's own + citizen, by path. + """ + with patch.object(fleet, "external_branches", return_value=[]): + undisturbed = registry_handler.get_branch_by_name("memory") + + with patch.object(fleet, "external_branches", return_value=[_external("memory", "impostor")]): + branch = registry_handler.get_branch_by_name("memory") + + assert undisturbed is not None and branch is not None, "the local @memory stopped resolving" + assert branch["path"] == undisturbed["path"], branch["path"] + assert branch["path"] == str(local_world / "src" / "memory"), branch["path"] + assert "impostor" not in branch["path"], branch["path"] + + def test_declaration_order_breaks_ties_among_externals(self, local_world): + first = _external("shared", "alpha") + second = _external("shared", "beta") + with patch.object(fleet, "external_branches", return_value=[first, second]): + branch = registry_handler.get_branch_by_name("shared") + + assert branch is not None, "a tie among externals resolved to nothing" + assert "alpha" in branch["path"], "the earlier declared root must win" + + def test_a_local_external_collision_is_logged_on_both_sides(self, local_world): + with patch.object(fleet, "external_branches", return_value=[_external("memory", "impostor")]): + with patch.object(registry_handler, "logger") as log: + registry_handler.get_all_branches() + + collisions = [c for c in log.warning.call_args_list if "collision" in str(c).lower()] + assert collisions, "a shadowed external citizen must not vanish quietly" + assert any("memory" in str(c) for c in collisions) + + +# --------------------------------------------------------------------------- +# The gateway is another branch's code — it must not take routing down +# --------------------------------------------------------------------------- + + +class TestGatewayFailureIsContained: + def test_local_resolution_survives_a_broken_gateway(self, local_world): + with patch.object(fleet, "external_branches", side_effect=RuntimeError("gateway down")): + assert registry_handler.get_branch_by_name("nosuchbranch") is None + + def test_a_broken_gateway_is_loud(self, local_world): + with patch.object(fleet, "external_branches", side_effect=RuntimeError("gateway down")): + with patch.object(registry_handler, "logger") as log: + registry_handler.get_branch_by_name("nosuchbranch") + + assert log.error.called, "losing the external tier must never pass in silence" + + +# --------------------------------------------------------------------------- +# The gateway's shape is another branch's contract — it can move +# --------------------------------------------------------------------------- + + +class TestMalformedRecordsAreSkippedNotTrusted: + """A record missing what resolution needs is refused BY NAME, never routed. + + @memory's record grew from three keys to six today. If it moves again, a + row this branch cannot key on must drop out with a line saying so — routing + a citizen whose path is None is a crash somewhere further from the cause. + """ + + @pytest.mark.parametrize( + "broken", + [ + {"path": _SOME_PATH, "registry": "X_REGISTRY.json"}, + {"name": "a", "registry": "X_REGISTRY.json"}, + {"name": "a", "path": _SOME_PATH}, + {"name": "", "path": _SOME_PATH, "registry": "X_REGISTRY.json"}, + ], + ids=["no-name", "no-path", "no-registry", "empty-name"], + ) + def test_a_record_missing_a_required_key_is_dropped(self, broken): + with patch.object(fleet, "external_branches", return_value=[broken]): + assert registry_handler._external_branches() == [] + + def test_the_dropped_record_is_named(self): + with patch.object(fleet, "external_branches", return_value=[{"name": "a"}]): + with patch.object(registry_handler, "logger") as log: + registry_handler._external_branches() + + assert log.warning.called, "a record dropped without a line is a citizen that vanished" + + def test_a_good_record_beside_a_broken_one_still_resolves(self): + good = _external("keeper", "alpha") + with patch.object(fleet, "external_branches", return_value=[{"name": "a"}, good]): + entries = registry_handler._external_branches() + + assert [e["name"] for e in entries] == ["keeper"] + + +# --------------------------------------------------------------------------- +# The whole chain, not just the lookup +# --------------------------------------------------------------------------- + + +class TestResolutionEndToEnd: + def test_an_external_citizen_resolves_all_the_way_through_resolve_branch(self, standin): + """resolve_branch is the door every routed command comes through. + + The link worth pinning is the LAST one: _validate_branch_path refuses a + branch path that escapes its project root, and an external citizen's + path escapes OURS by definition. It passes because the root it is + checked against is the external registry's own parent, not AIPass — + which is only true because get_branch_with_registry hands back the + sealed registry the entry was read from. + """ + from aipass.drone.apps.modules.resolver import resolve_branch + + with patch.object( + fleet, + "external_branches", + side_effect=lambda _root=None, **kw: registry_scope.external_branches(standin, **kw), + ): + resolved = resolve_branch("@alpha_agent") + + # Path parts, not a string suffix — Windows CI runs this suite and its + # separator is not "/". This pin predates tonight and would have red + # there whatever the resolver did. + assert Path(resolved).parts[-4:] == ("alpha", "src", "alpha", "worker"), resolved + + def test_an_undeclared_repo_is_still_refused(self, standin): + from aipass.drone.apps.modules.resolver import BranchNotFoundError, resolve_branch + + with patch.object( + fleet, + "external_branches", + side_effect=lambda _root=None, **kw: registry_scope.external_branches(standin, **kw), + ): + with pytest.raises(BranchNotFoundError): + resolve_branch("@gamma_agent") + + +class TestTheTierIsScopedToTheProjectBeingResolved: + """The real machine's declared roots must not leak into another project. + + This is the regression that got through: the tier read THIS checkout's + AIPASS_ROOTS.json no matter which registry the caller had pointed at, so the + moment the file was blessed, eight enumeration tests across two files began + seeing six real external citizens. The suite already isolated the AIPASS_HOME + source with a fixture that unsets its env var — a third source with no + equivalent scope is a third source nobody can test around. + """ + + def test_enumeration_scopes_the_tier_to_the_registry_it_was_handed(self, standin, monkeypatch): + """get_all_branches must read the roots beside ITS registry, not the process cwd. + + Pointed at a stand-in home, enumeration returns that home's two declared + citizens and none of this machine's — the leak, stated as an assertion. + """ + from aipass.drone.apps.handlers.registry_handler import ( + reset_registry_path, + set_registry_path, + ) + + monkeypatch.delenv("AIPASS_HOME", raising=False) # the SECOND source, isolated the way the suite already does + registry = standin / "AIPASS_REGISTRY.json" + registry.write_text( + json.dumps( + { + "metadata": {"id": "7087bb93-570f-4b9a-b035-4fd7f570200e"}, + "branches": [], + } + ) + ) + try: + set_registry_path(registry) + names = {b["name"] for b in registry_handler.get_all_branches()} + finally: + reset_registry_path() + + assert names == {"alpha_agent", "beta_agent"} # enumeration lowercases names + + def test_the_scope_follows_the_registry_not_the_checkout(self, standin, tmp_path): + """A project WITH declared roots sees its own, not ours.""" + assert registry_handler._external_branches(repo_root=standin) + assert registry_handler._external_branches(repo_root=tmp_path / "nowhere") == [] + + +class TestTheHomeFallbackDoesNotSwallowTheThirdSource: + """A MISS in the AIPASS_HOME registry must not end the search. + + Found by running the suite from a machine whose AIPass home is not the + project registry — the state @devpulse asked me to prove green. Two pins in + this file went red there and neither was about the roots file: on that + layout ``home_path != primary_path``, and ``get_branch_by_name`` RETURNED + the home registry's lookup whether or not it found anything, so the external + tier below it was unreachable. Its sibling ``get_branch_with_registry`` + guards the same lookup with ``if branch is not None`` and falls through. + Two functions answering the same question two ways is the defect; the + external tier just made it observable. + """ + + def test_a_home_registry_miss_still_reaches_a_declared_root(self, tmp_path, monkeypatch): + home = tmp_path / "elsewhere" + home.mkdir() + (home / "AIPASS_REGISTRY.json").write_text(json.dumps({"branches": []})) + monkeypatch.setenv("AIPASS_HOME", str(home)) + + with patch.object(fleet, "external_branches", return_value=[_external("shared", "alpha")]): + branch = registry_handler.get_branch_by_name("shared") + + assert branch is not None, "the home registry missed — it did not answer" + assert branch["name"] == "shared" + + def test_a_home_registry_hit_still_wins_over_an_external(self, tmp_path, monkeypatch): + """Falling through on a miss must not turn into skipping the home tier.""" + home = tmp_path / "elsewhere" + home.mkdir() + (home / "AIPASS_REGISTRY.json").write_text( + json.dumps({"branches": [{"name": "shared", "path": "src/home/shared", "email": "@shared"}]}) + ) + monkeypatch.setenv("AIPASS_HOME", str(home)) + + with patch.object(fleet, "external_branches", return_value=[_external("shared", "alpha")]): + branch = registry_handler.get_branch_by_name("shared") + + assert branch is not None, "the AIPass home citizen did not resolve" + assert "home" in str(branch["path"]), "AIPass home outranks a declared root" + + +class TestTheStandInMatchesTheRealReader: + """The fixture's shape is pinned to @memory's reader, not to my memory of it. + + The record moved twice in one day — three keys to six, then six to five — + and both times I learned about it by mail. A stand-in that describes a shape + the reader no longer produces is how a guard stops being observable, so the + comparison is a test now: @memory's REAL reader runs against a stand-in root + here, and its keys must be exactly the keys ``_external`` builds. + """ + + def test_the_helper_builds_exactly_the_keys_the_gateway_returns(self, standin): + produced = registry_scope.external_branches(standin) + + assert produced, "the stand-in declares two roots — the reader found neither" + assert set(produced[0]) == set(_external("shared", "alpha")), ( + f"reader returns {sorted(produced[0])}, fixture builds {sorted(_external('shared', 'alpha'))}" + ) + + +class TestTheGatewaysImportCannotTakeDroneDown: + """Containment must hold at IMPORT, not only at the call. + + ``TestGatewayFailureIsContained`` pinned a gateway that RAISES when called. + The import itself was not pinned, and it was the unguarded half: + ``registry_handler`` carried ``from aipass.memory.apps.modules import fleet`` + at module level, so anything raising inside @memory's import chain killed + every import of drone — the router, ``drone rm``, ``drone systems``, all of + it — before a single guard in this branch could run. + + Not hypothetical, and it is the CI red this class was written for. + @memory's ``registry_scope.py`` runs ``REPO_ROOT = find_repo_root()`` at + module level, and ``find_repo_root`` falls back to ``Path.cwd()`` when the + walk up from ``__file__`` finds no ``AIPASS_REGISTRY.json``. A clean + checkout has no registry (it is gitignored and machine-local), so a bare + runner takes that fallback — and a process whose directory was deleted + raises ENOENT there. Reported to @memory as theirs to fix; contained here + because a consumer that dies with its dependency has no containment at all. + + The finder is a real import failure, not a mock: nothing this branch can + patch reproduces a module that will not import. + """ + + def test_drone_imports_when_the_gateway_module_cannot_be_imported(self): + import subprocess + import sys + import textwrap + + probe = textwrap.dedent( + """ + import importlib.abc, sys + + class Boom(importlib.abc.MetaPathFinder): + def find_spec(self, name, path, target=None): + if name.startswith("aipass.memory"): + raise FileNotFoundError("simulated bare-world crash inside @memory") + return None + + sys.meta_path.insert(0, Boom()) + from aipass.drone.apps.handlers import registry_handler + print("imported", registry_handler.__name__.rsplit(".", 1)[-1]) + """ + ) + result = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert "imported registry_handler" in result.stdout + + def test_a_gateway_that_will_not_import_is_contained_and_loud(self, tmp_path): + """The tier is lost, resolution is not, and the loss is on the record.""" + import builtins + + real_import = builtins.__import__ + + def refuse_memory(name, *args, **kwargs): + if name.startswith("aipass.memory"): + raise ImportError("simulated bare-world crash inside @memory") + return real_import(name, *args, **kwargs) + + with patch.object(builtins, "__import__", refuse_memory): + with patch.object(registry_handler, "logger") as log: + entries = registry_handler._external_branches(repo_root=tmp_path) + + assert entries == [] + assert log.error.called, "losing the tier at import must never pass in silence" diff --git a/src/aipass/drone/tests/test_import_dead_cwd.py b/src/aipass/drone/tests/test_import_dead_cwd.py new file mode 100644 index 000000000..249481b5f --- /dev/null +++ b/src/aipass/drone/tests/test_import_dead_cwd.py @@ -0,0 +1,775 @@ +# =================== AIPass ==================== +# Name: test_import_dead_cwd.py +# Description: Every drone module imports without a readable working directory +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Drone must import, and must keep logging, with no working directory. + +THE MECHANISM, measured on the Windows CI gate 2026-08-31 (@memory's finding, +relayed by @devpulse). ``ntpath.realpath`` calls ``os.getcwd()`` +UNCONDITIONALLY — on its first lines, before it even asks whether the path is +absolute — where ``posixpath`` only reads the cwd for a relative one. And +``Path.resolve()`` routes through ``os.path.realpath``. So on Windows every +module-level ``Path(__file__).resolve()`` is an import-time working-directory +dependency, and a process whose cwd is gone cannot import the module at all. +Guarding INSIDE that module's functions changes nothing: the import died before +any of them existed. + +``inspect.stack()`` carries the same defect one layer down and needs a +different world to convict it. It builds a ``FrameInfo`` per frame, and for a +frame whose filename is a PSEUDO-file it reaches ``getmodule()``, whose +``os.path.realpath(f)`` sits outside every ``try`` in that function. On POSIX +the equivalent raise happens EARLIER, inside ``getabsfile()``, where +``inspect`` catches it — which is exactly why a call on drone's every-import +path survived years of Linux CI carrying this. + +TWO WORLDS, and @seedgo's asymmetry is why both are here rather than one: + +* **World A** emulates ntpath — ``os.path.realpath`` is wrapped to read + ``os.getcwd()`` first, then ``os.getcwd`` is denied. This convicts a raw + ``resolve()``. It does NOT convict ``inspect.stack()`` on Linux, because + ``getabsfile`` raises inside inspect's own catch before ``getmodule`` is + reached. +* **World B** denies ``os.path.realpath`` outright while ``abspath`` keeps + working. This is what reaches ``getmodule``'s unguarded call and convicts + ``inspect.stack()``. + +THIRD INGREDIENT for world B (@hooks): the frame must be ```` — +an interpreter ``-c`` or ``compile()`` frame — and NEVER ````. A heredoc-fed +child puts ```` in ``linecache.cache``, ``getsourcefile`` early-returns, +and the probe reports green while the same world kills imports for real. Every +assertion below is preceded by a control that states whether its world is armed, +so a probe that quietly stopped biting cannot pass itself off as a cure. + +WHAT DRONE CARRIED, measured not estimated. Before this build, 63 of 63 drone +modules died on import in BOTH worlds — every one of them at +``handlers/__init__.py:57``, the module-level ``_BRANCH_ROOT`` resolve. That +line MASKED everything under it, which is why the count only became true as +cures landed: curing the guard took it to 56/63 and revealed +``json_handler.py:41``, and curing the three module-level sites took it to 0. +Session 74's inner ``try/except OSError`` around the frame-filename resolve was +below both crash lines the whole time — decorative, exactly as @trigger and +@prax found in their own trees. + +And one live site that no import probe reaches: ``log_operation`` is the audit +line drone writes on essentially every operation, and its +``_get_caller_module_name`` called ``inspect.stack()``. Under world B, called +across a ```` frame, it raised ``FileNotFoundError`` — drone's logging +taking down the caller it was logging for, on the very recovery lane (``drone +rm`` from a directory that was just deleted) sessions 68 and 77 exist to keep +alive. +""" + +import ast +import subprocess +import sys +from pathlib import Path + +import pytest + +# Other branches' import-time code is held CONSTANT: preloaded in the healthy +# world, before any denial. Their cure is their own build; this file measures +# drone's sites and must not go red in someone else's name. These are drone's +# only cross-branch module-level imports, and they are TEMPORARY — delete a +# line once that branch's own dead-cwd pin is green. +_PRELOAD = """ +import aipass.prax # noqa: F401 +import aipass.prax.apps.modules.logger # noqa: F401 +import aipass.cli.apps.modules # noqa: F401 +import aipass.api # noqa: F401 +""" + +# THE ACCESSOR PATCH, and it is the difference between a world and a costume. +# +# CI found this on the Python 3.10 leg of 8550ed10: the arming probe below +# printed RESOLVE_DIES: NO and the pin REFUSED rather than passing vacuously. +# @devpulse relayed the mechanism and @memory corrected the first diagnosis by +# reading CPython's source, so this is the corrected version: +# +# CPython 3.10 Lib/pathlib.py +# 358: realpath = staticmethod(os.path.realpath) # _NormalAccessor +# 1077: s = self._accessor.realpath(self, strict=strict) # Path.resolve +# +# 3.10 DOES delegate resolve() to os.path.realpath — it simply took its COPY +# when pathlib was first imported. So a world that rebinds ``os.path.realpath`` +# afterwards rebinds a name nothing will read again, and every pin under it is +# green while asserting nothing. 3.11 deleted the accessor and calls +# os.path.realpath at use, which is why exactly one interpreter reddened. +# +# The cure patches the captured accessor as well, which also makes the world +# ORDER-INDEPENDENT: a child may import pathlib before or after installing it. +# No version table and no skipif — the same text arms on every interpreter. +# +# TWO EDGES, both @memory's, both honoured: +# * ``staticmethod`` AND ``*a`` are redundant with each other on purpose. A +# plain function stored on a class arrives BOUND through an instance, so it +# would eat the accessor as its first positional argument. staticmethod +# prevents the binding; ``*a`` survives it if a future edit drops the +# staticmethod. Keeping one and deleting the other is how the remaining half +# silently becomes load-bearing. +# * a bound wrapper that ate the path would still call os.getcwd() first and +# still raise, so a raise-shaped probe stays green FOR THE WRONG REASON. +# test_the_ntpath_wrapper_resolves_the_path_not_the_accessor pins the value +# it returns, through a sentinel so the answer is the wrapper's and not the +# host path module's. +# +# THE PATCH IS CLASS-LEVEL ONLY, AND THAT IS A MEASUREMENT RATHER THAN A COPY. +# The first cut here also assigned ``_pathlib._normal_accessor.realpath`` on the +# INSTANCE. It looked like harmless belt-and-braces and it was not: an instance +# attribute is never bound, so it SHADOWED the class attribute and made the +# class-level ``staticmethod`` unfalsifiable — mutant M24b dropped it and all 16 +# pins stayed green. Removing the instance line turns that same mutant red. 3.10 +# stores no per-instance ``realpath``, so the class patch is what its +# ``self._accessor.realpath(...)`` actually reaches; the extra line bought +# nothing and cost the only pin that watches the binding. @memory has adopted the +# same refusal, crediting drone's M24b, against @spawn's belt-and-braces form. +_NTPATH_WRAPPER = """ +import os + +_real_realpath = os.path.realpath + + +def _ntpath_condition(path, *a, **kw): + os.getcwd() # ntpath.realpath reads cwd before checking absoluteness + return _real_realpath(path, *a, **kw) + + +os.path.realpath = _ntpath_condition + +import pathlib as _pathlib_rp + +if hasattr(_pathlib_rp, "_NormalAccessor"): + _pathlib_rp._NormalAccessor.realpath = staticmethod(_ntpath_condition) +""" + +_DENY_GETCWD = """ +def _dead_getcwd(*a, **k): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd +""" + +_WORLD_A = _NTPATH_WRAPPER + _DENY_GETCWD + +_WORLD_B = """ +import os + + +def _dead_realpath(path, *a, **kw): + raise FileNotFoundError(2, "realpath denied", "") + + +os.path.realpath = _dead_realpath + +import pathlib as _pathlib_dr + +if hasattr(_pathlib_dr, "_NormalAccessor"): + _pathlib_dr._NormalAccessor.realpath = staticmethod(_dead_realpath) +""" + +# 3.10's pathlib reduced to the two lines that matter, installed ON PURPOSE so +# the accessor row is falsifiable on an interpreter that no longer has one. It +# captures the REAL os.path.realpath BEFORE the world is installed, which is +# exactly the ordering 3.10 produces by importing pathlib early. +_FAKE_ACCESSOR = """ +import os +import pathlib as _p + + +class _NormalAccessor: + realpath = staticmethod(os.path.realpath) + + +_p._NormalAccessor = _NormalAccessor +_p._normal_accessor = _NormalAccessor() +""" + +# @memory's SENTINEL, and it is the cure for drone's one windows-setup red on +# c82c3d34. The return-value pin below asked "did the wrapper receive the PATH or +# the accessor?" and answered it by reading what came back from the real +# ``os.path.realpath``. On Linux that discriminates. On nt it does not, because +# ``ntpath.realpath('/tmp')`` returns ``D:\\tmp`` — a POSIX-absolute literal is +# DRIVE-RELATIVE there — so the pin reported "it is arriving bound and resolving +# the accessor" about a wrapper that had worked perfectly. The mechanism it +# guards was right; the string it compared was a POSIX fact asserted as +# universal, which is the species this whole file exists to catch, committed +# inside the instrument built to catch it. +# +# @memory's rule, paid for by four of their own reds the same night: when a probe +# asks "did my patch reach X", let X have captured a SENTINEL rather than the +# real function — otherwise the original's own platform behaviour answers the +# question for you. This one returns its argument and touches no filesystem, no +# cwd and no path module, so whatever comes back afterwards is the wrapper's +# doing on any platform. It also subsumes the drive-relative symptom entirely: +# with nothing resolving the literal, there is no spelling to disagree about. +_SENTINEL_REALPATH = """ +import os + + +def _sentinel_realpath(path, *a, **kw): + return path + + +os.path.realpath = _sentinel_realpath +""" + +# The OPPOSITE platform, for the litmus @devpulse requires: run the probe under +# an nt emulation and require the verdict not to move. +# +# @flow's M3 TRAP, honoured rather than repeated: do NOT alias ``ntpath.realpath`` +# on this host. CPython's ntpath.py cannot import ``nt`` on POSIX and falls back +# to ``realpath = abspath``, and ``ntpath.abspath`` reads the cwd only for a +# RELATIVE path — so the alias emulates THIS host wearing an nt label and proves +# nothing. The win32 branch is built by name instead: read the cwd +# UNCONDITIONALLY (ntpath.py:678), and spell the answer the way nt does, where a +# POSIX-absolute literal is drive-relative. ``D:`` is CI's own drive, from the +# failure log. +_NT_EMULATION = """ +import os + + +def _win32_realpath(path, *a, **kw): + os.getcwd() # unconditional on nt, unlike posixpath + p = os.fspath(path) + if p[:1] in ("/", "\\\\"): + return "D:" + p.replace("/", "\\\\") + return "D:\\\\emulated_cwd\\\\" + p.replace("/", "\\\\") + + +os.path.realpath = _win32_realpath +""" + +# Does THIS interpreter's resolve() reach the denied call for an ABSOLUTE path? +_RESOLVE_CONTROL = """ +import pathlib + +try: + pathlib.Path(pathlib.__file__).resolve() + print("RESOLVE_DIES: NO") +except OSError: + print("RESOLVE_DIES: YES") +""" + +# The control for world B, and it MUST ride a frame — see the module +# docstring. compile(..., "") gives the frame a pseudo-filename with +# nothing in linecache, which is the shape getmodule's unguarded realpath is +# reached for. +_STACK_CONTROL = """ +import inspect + + +def _probe(): + try: + inspect.stack() + return "NO" + except OSError: + return "YES" + + +_ns = {"_probe": _probe, "out": None} +exec(compile("out = _probe()", "", "exec"), _ns) +print("STACK_DIES: " + _ns["out"]) +""" + + +def _drone_modules() -> list[str]: + """Every importable module under ``aipass.drone.apps``, by walking the tree. + + Named from the filesystem rather than from a hand-written list: the whole + species this file is about is a fix landing on some of N identical paths, + and a list in a test is one more place for N to be undercounted. + """ + import aipass.drone.apps as drone_apps + + root = Path(drone_apps.__file__).parent + names = set() + for source in sorted(root.rglob("*.py")): + if "__pycache__" in source.parts: + continue + rel = source.relative_to(root).with_suffix("") + parts = [p for p in rel.parts if p != "__init__"] + names.add(".".join(["aipass.drone.apps", *parts])) + return sorted(names) + + +def _run_world(world: str, control: str, body: str) -> subprocess.CompletedProcess: + script = _PRELOAD + world + control + body + return subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=300, + ) + + +@pytest.fixture(scope="module") +def drone_modules() -> list[str]: + modules = _drone_modules() + assert len(modules) > 40, f"the module walk found only {len(modules)} — it is not measuring the tree" + return modules + + +class TestEveryModuleImportsWithoutACwd: + """The import fan, in both worlds, with the world's own liveness asserted.""" + + IMPORT_BODY = """ +import importlib +import sys +import traceback + +dead = [] +for name in {names!r}: + try: + importlib.import_module(name) + except OSError: + tb = traceback.extract_tb(sys.exc_info()[2]) + site = "unknown" + for fr in tb: + if "aipass" in fr.filename and "drone" in fr.filename: + site = fr.filename + ":" + str(fr.lineno) + dead.append(name + " -> " + site) + except Exception: + pass # not this file's question + +print("DEAD: " + str(len(dead))) +for entry in dead: + print(" " + entry) +print("SWEPT: " + str(len({names!r}))) +""" + + def test_world_a_ntpath_emulation_kills_no_drone_import(self, drone_modules): + """A raw ``Path(__file__).resolve()`` anywhere on the import fan reds this.""" + result = _run_world(_WORLD_A, _RESOLVE_CONTROL, self.IMPORT_BODY.format(names=drone_modules)) + + assert result.returncode == 0, result.stdout + result.stderr + assert "RESOLVE_DIES: YES" in result.stdout, ( + "the ntpath world did not arm — every assertion below it would be vacuous.\n" + result.stdout + ) + assert "DEAD: 0" in result.stdout, result.stdout + assert f"SWEPT: {len(drone_modules)}" in result.stdout, result.stdout + + def test_world_b_denied_realpath_kills_no_drone_import(self, drone_modules): + """Harsher, and the only world that convicts ``inspect.stack()``.""" + result = _run_world(_WORLD_B, _STACK_CONTROL, self.IMPORT_BODY.format(names=drone_modules)) + + assert result.returncode == 0, result.stdout + result.stderr + assert "STACK_DIES: YES" in result.stdout, ( + "world B did not arm — inspect.stack() survived it, so nothing here convicts that call.\n" + result.stdout + ) + assert "DEAD: 0" in result.stdout, result.stdout + + +class TestTheAuditLineSurvivesTheWorldItLogsIn: + """``log_operation`` is reached at runtime, so no import probe covers it. + + The stack it walks is the CALLER'S, so the shape that convicts it is a + ```` frame — a routed subprocess, a hook, anything exec'd — which is + precisely what drone's own router produces. + """ + + BODY = """ +import os +import tempfile + +os.environ["AIPASS_TEST_LOG_DIR"] = tempfile.mkdtemp() +from aipass.drone.apps.handlers.json import json_handler + +g = {"jh": json_handler, "name": None} +try: + exec(compile("name = jh._get_caller_module_name()", "", "exec"), g) + print("CALLER_NAME: " + str(g["name"])) +except OSError as exc: + print("CALLER_NAME DIED: " + type(exc).__name__) + +try: + exec(compile("jh.log_operation('dead_cwd_probe', {'k': 1})", "", "exec"), g) + print("LOG_OPERATION: SURVIVED") +except OSError as exc: + print("LOG_OPERATION DIED: " + type(exc).__name__) +""" + + def test_log_operation_survives_a_string_frame_with_realpath_denied(self): + result = _run_world(_WORLD_B, _STACK_CONTROL, self.BODY) + + assert result.returncode == 0, result.stdout + result.stderr + assert "STACK_DIES: YES" in result.stdout, ( + "world B did not arm — this test would pass against the uncured call.\n" + result.stdout + ) + assert "LOG_OPERATION: SURVIVED" in result.stdout, result.stdout + assert "CALLER_NAME DIED" not in result.stdout, result.stdout + # It must still ANSWER, not merely not-crash: returning "unknown" for + # every caller would satisfy the line above and destroy the audit trail. + assert "CALLER_NAME: " in result.stdout, ( + "the caller name stopped being read from the frame: " + result.stdout + ) + + +class TestTheWorldArmsOnEveryInterpreter: + """The 3.10 row, made falsifiable on an interpreter that is not 3.10. + + STATED PLAINLY BECAUSE IT LIMITS WHAT THIS FILE PROVES: no Python 3.10 + exists on this machine (3.12 only, checked). So the 3.10 row is DERIVED from + CI's red and REPRODUCED under the emulated accessor below — it is not a live + measurement on a real 3.10, and this file should not be read as one. What is + measured live here is the accessor SHAPE and the cure's behaviour against it, + which is the part a future edit can break. + + CI's failure was not a bug in drone's code — it was the world failing to + arm, and the arming probe REFUSING instead of passing vacuously is the only + reason anyone found out. That is the discipline working, and it is also the + limit of it: a probe can only tell you the world is dead on the interpreter + that runs it. Nothing here could have said so from a 3.12 laptop. + + So the accessor shape is BUILT rather than waited for. Each test below states + which half of the cure it measures, and the middle one reproduces CI's + failure exactly — a bare module-attribute patch, a pre-captured accessor, and + a denial that never lands. + """ + + PROBE = """ +import pathlib as _p + +try: + _p._normal_accessor.realpath("/tmp") + print("ACCESSOR_DENIED: NO") +except OSError: + print("ACCESSOR_DENIED: YES") +""" + + BARE_PATCH_ONLY = """ +import os + + +def _bare(path, *a, **kw): + raise FileNotFoundError(2, "realpath denied", "") + + +os.path.realpath = _bare +""" + + @staticmethod + def _run(script: str) -> subprocess.CompletedProcess: + return subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=120) + + def test_a_bare_module_patch_does_not_reach_a_pre_captured_accessor(self): + """CI's Python 3.10 failure, reproduced here. This is the red-first half. + + The accessor captured the real function before the patch, so rebinding + ``os.path.realpath`` rebinds a name nothing reads again. If this test + ever goes green, the interpreter stopped being able to express the bug + and the two below prove less than they claim. + """ + result = self._run(_FAKE_ACCESSOR + self.BARE_PATCH_ONLY + self.PROBE) + + assert result.returncode == 0, result.stdout + result.stderr + assert "ACCESSOR_DENIED: NO" in result.stdout, ( + "a bare os.path.realpath patch reached a pre-captured accessor — " + "then CI's 3.10 red is not reproducible here and this file cannot " + "defend the cure: " + result.stdout + ) + + def test_the_shipped_world_reaches_a_pre_captured_accessor(self): + """The cure, measured against the shape that defeated the bare patch.""" + result = self._run(_FAKE_ACCESSOR + _WORLD_B + self.PROBE) + + assert result.returncode == 0, result.stdout + result.stderr + assert "ACCESSOR_DENIED: YES" in result.stdout, ( + "the shipped world left a pre-captured accessor holding the real " + "function — this is exactly the 3.10 red: " + result.stdout + ) + + def test_world_a_also_reaches_a_pre_captured_accessor(self): + """Both worlds carry the cure, so both are measured against the shape. + + World A is the one CI actually reddened, and patching only world B would + leave the exact failure in place while the test named after it passed. + """ + result = self._run(_FAKE_ACCESSOR + _WORLD_A + self.PROBE) + + assert result.returncode == 0, result.stdout + result.stderr + assert "ACCESSOR_DENIED: YES" in result.stdout, ( + "world A left a pre-captured accessor holding the real function — " + "this is CI's 3.10 red exactly: " + result.stdout + ) + + def test_the_shipped_world_still_arms_where_no_accessor_exists(self): + """3.11+ — the ``hasattr`` guard must not turn the world into a no-op.""" + result = self._run(_WORLD_A + _RESOLVE_CONTROL) + + assert result.returncode == 0, result.stdout + result.stderr + assert "RESOLVE_DIES: YES" in result.stdout, result.stdout + + RESOLVE_PROBE = """ +import pathlib as _p + +print("RESOLVED: " + str(_p._normal_accessor.realpath("/tmp"))) +""" + + def test_the_ntpath_wrapper_resolves_the_path_not_the_accessor(self): + """@memory's return-value pin, cured of the platform it was importing. + + THE CLAIM is unchanged and it is still not decoration: every other + assertion about world A is RAISE-shaped, and a wrapper that arrived bound + would eat the real path into ``*a``, resolve the accessor object instead, + and STILL call ``os.getcwd()`` first — so it would still raise and every + raise-shaped probe would stay green for entirely the wrong reason. Only + the return value separates the two, so it is checked with the denial OFF. + + WHAT CHANGED is what the accessor captured. This pin used to read the + answer back through the host's real ``os.path.realpath``, which made the + verdict a fact about the host's path module — and on nt it returned + ``D:\\tmp`` and the pin accused a wrapper that was working. It reads + through a sentinel now, so the only thing that can change the answer is + the wrapper's own argument handling, which is the thing under test. + """ + result = self._run(_SENTINEL_REALPATH + _FAKE_ACCESSOR + _NTPATH_WRAPPER + self.RESOLVE_PROBE) + + assert result.returncode == 0, result.stdout + result.stderr + assert "RESOLVED: /tmp" in result.stdout, ( + "the wrapper did not receive the path it was handed — it is arriving " + "bound and passing the accessor through: " + result.stdout + ) + + def test_the_nt_emulation_reproduces_the_ci_red_without_the_sentinel(self): + """Red-first, locally: the failure @devpulse relayed, rebuilt on this host. + + Without the sentinel the probe resolves a POSIX literal through whatever + path module the host has. Under the nt emulation that is ``D:\\tmp`` — + CI's exact string, from a wrapper doing exactly the right thing. + + If this ever stops producing ``D:\\tmp``, the emulation has stopped being + able to express the bug and the litmus below proves nothing. + """ + result = self._run(_NT_EMULATION + _FAKE_ACCESSOR + _NTPATH_WRAPPER + self.RESOLVE_PROBE) + + assert result.returncode == 0, result.stdout + result.stderr + assert "RESOLVED: D:\\tmp" in result.stdout, ( + "the nt emulation no longer reproduces the drive-relative spelling, " + "so it cannot defend the sentinel: " + result.stdout + ) + + def test_the_sentinel_gives_the_same_verdict_under_either_platform(self): + """@devpulse's litmus: run the probe under the OPPOSITE platform's + emulation and require the verdict not to move. + + This is the test that would have caught the original red on Linux. The + two runs differ only in which path module the host appears to have, and a + pin about argument handling must not be able to notice that. + + THE COMPARISON IS THE PIN, not the ``RESOLVED: /tmp`` line under it, and + that was measured rather than assumed. Neutering the comparison alone is + an INVALID mutant by @daemon's round-5 rule — an assertion that currently + holds cannot be killed by deleting it, so of course it stays green. The + valid form is the compound: revert the sentinel AND neuter the + comparison, and the whole file passes. So the cross-platform equality is + the only thing here that catches CI's actual defect. Do not delete it as + redundant with the literal check below; the literal check is what CI was + already doing when it went red. + """ + here = self._run(_SENTINEL_REALPATH + _FAKE_ACCESSOR + _NTPATH_WRAPPER + self.RESOLVE_PROBE) + as_nt = self._run(_NT_EMULATION + _SENTINEL_REALPATH + _FAKE_ACCESSOR + _NTPATH_WRAPPER + self.RESOLVE_PROBE) + + assert here.returncode == 0, here.stdout + here.stderr + assert as_nt.returncode == 0, as_nt.stdout + as_nt.stderr + assert here.stdout == as_nt.stdout, ( + "the verdict moved when the platform did — this pin is measuring the " + f"host rather than the wrapper.\n here: {here.stdout!r}\n as nt: {as_nt.stdout!r}" + ) + assert "RESOLVED: /tmp" in here.stdout, here.stdout + + +class TestTheCallerIsNoneBranchRunsAndReturns: + """The branch the AST ban was built for, watched BEHAVIOURALLY as well. + + THE CORRECTION, @spawn's, relayed by @devpulse 2026-08-31 after this file + shipped. The round-4 guidance said this branch is unreachable and only a + parse can watch it. The true sentence is narrower: it is unreachable from + IMPORT-shaped pins, because ``apps/__init__.py`` always supplies a real-file + frame — the nine-branch reproduction stands, and drone's own M3 reproduced it + (restoring the walk killed exactly one test, the AST ban). + + But CALLING ``_guard_branch_access()`` directly from an interpreter ``-c`` + child reaches it: every frame is then either string-pseudo or importlib, both + skipped, so ``_find_real_caller`` returns None and the branch RUNS. Under a + realpath denial a regrown ``inspect.stack()`` walk dies there; the cured plain + return survives. + + TWO ARMING PROBES, not one, and the second is the one that matters. Probe 1 + proves the denial bites at all. Probe 2 proves ``_find_real_caller`` actually + returned None — without it the world could silently exercise a DIFFERENT path + (a real-file frame sneaking onto the stack sends the guard down the + containment check instead, where it also returns, and the pin would report + green having never entered the branch it names). + + The AST ban below is kept rather than replaced. It needs no subprocess, it + names the defect precisely, and it catches reintroductions in files this probe + never calls into. Two instruments, one defect, different blind spots. + """ + + BODY = """ +from aipass.drone.apps.handlers import _find_real_caller, _guard_branch_access + +# ARMING PROBE 2: the branch under test is the one actually entered. +caller, _line = _find_real_caller() +print("CALLER_IS_NONE: " + ("YES" if caller is None else "NO (" + str(caller) + ")")) + +try: + _guard_branch_access() + print("GUARD: RETURNED") +except OSError as exc: + print("GUARD DIED: " + type(exc).__name__) +except ImportError: + print("GUARD: BLOCKED") +""" + + def test_the_guard_returns_from_a_string_frame_with_realpath_denied(self): + result = _run_world(_WORLD_B, _STACK_CONTROL, self.BODY) + + assert result.returncode == 0, result.stdout + result.stderr + assert "STACK_DIES: YES" in result.stdout, ( + "world B did not arm — a regrown walk would survive this pin.\n" + result.stdout + ) + assert "CALLER_IS_NONE: YES" in result.stdout, ( + "the probe never entered the caller-is-None branch, so it proves nothing about it.\n" + result.stdout + ) + assert "GUARD: RETURNED" in result.stdout, result.stdout + + +class TestNoModuleLevelLocationCallSurvives: + """The structural half, because behaviour cannot reach every reintroduction. + + @hooks (M7), reproduced here as M3: restoring ``inspect.stack()`` in + ``_guard_branch_access``'s caller-is-None branch left every IMPORT-shaped pin + green, because ``apps/__init__.py`` always supplies a real-file frame so no + import probe enters that branch. A parse sees it without a subprocess. + + The claim was narrowed 2026-08-31 (@spawn via @devpulse) and the narrowing is + above: a direct call from a ``-c`` child does reach it. "Unreachable from + import-shaped pins" is the true sentence, not "unreachable". + + The ban is on the CALL — ``ast.Call`` whose func is ``inspect.stack`` — never + on the string, because this file and the cured modules SPELL the defect in + their docstrings to explain it, and a string ban would convict the + explanation along with the thing. + """ + + BANNED_ATTRS = frozenset({"resolve", "cwd", "getcwd", "realpath", "abspath"}) + + @staticmethod + def _apps_sources() -> list[Path]: + import aipass.drone.apps as drone_apps + + root = Path(drone_apps.__file__).parent + return [p for p in sorted(root.rglob("*.py")) if "__pycache__" not in p.parts] + + @staticmethod + def _module_level_location_calls(tree: ast.Module) -> list[tuple[int, str]]: + """Calls that infer a location and are evaluated when the module loads.""" + found = [] + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + for sub in ast.walk(node): + if not isinstance(sub, ast.Call): + continue + func = sub.func + attr = func.attr if isinstance(func, ast.Attribute) else None + if attr in TestNoModuleLevelLocationCallSurvives.BANNED_ATTRS: + found.append((sub.lineno, ast.unparse(sub))) + return found + + @staticmethod + def _inspect_stack_calls(tree: ast.Module) -> list[int]: + """``inspect.stack()`` calls at ANY depth — the unreachable-branch case.""" + found = [] + for sub in ast.walk(tree): + if not isinstance(sub, ast.Call): + continue + func = sub.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "stack" + and isinstance(func.value, ast.Name) + and func.value.id == "inspect" + ): + found.append(sub.lineno) + return found + + def test_no_module_level_resolve_or_cwd_read_anywhere_in_apps(self): + offenders = [] + for source in self._apps_sources(): + tree = ast.parse(source.read_text(encoding="utf-8")) + for lineno, text in self._module_level_location_calls(tree): + offenders.append(f"{source.name}:{lineno} {text}") + + assert offenders == [], ( + "module-level location calls crash the import on a Windows box with no cwd; " + "route them through handlers/module_root.module_file(): " + ", ".join(offenders) + ) + + def test_no_inspect_stack_call_survives_in_apps(self): + offenders = [] + for source in self._apps_sources(): + tree = ast.parse(source.read_text(encoding="utf-8")) + for lineno in self._inspect_stack_calls(tree): + offenders.append(f"{source.name}:{lineno}") + + assert offenders == [], ( + "inspect.stack() reaches getmodule's unguarded os.path.realpath; " + "walk sys._getframe over f_code.co_filename instead: " + ", ".join(offenders) + ) + + # -- negative controls, both directions ------------------------------- + # + # A checker that convicts nothing and a checker that convicts everything + # look identical in a green summary. Both detectors are run against source + # they MUST flag and source they must NOT. + + def test_the_module_level_detector_convicts_a_module_level_resolve(self): + tree = ast.parse("from pathlib import Path\nROOT = Path(__file__).resolve().parents[3]\n") + assert self._module_level_location_calls(tree), "the detector is blind to the exact line it exists to ban" + + def test_the_module_level_detector_clears_the_same_call_inside_a_function(self): + tree = ast.parse("from pathlib import Path\ndef f():\n return Path(__file__).resolve()\n") + assert self._module_level_location_calls(tree) == [], ( + "a resolve() inside a function is reached at CALL time, not import time — " + "convicting it would ban the guarded helper this cure is built on" + ) + + def test_the_stack_detector_convicts_a_call_in_an_unreachable_branch(self): + """@hooks' M7, reproduced: the branch no import probe can enter.""" + tree = ast.parse( + "import inspect\n" + "def guard(caller):\n" + " if caller is None:\n" + " for frame in inspect.stack():\n" + " pass\n" + " return\n" + " return\n" + ) + assert self._inspect_stack_calls(tree), "the reintroduction @hooks measured would land unnoticed" + + def test_the_stack_detector_clears_a_docstring_that_names_the_defect(self): + """The reason this is an AST ban and not a grep. + + The source is a SYNTHETIC literal on purpose, and it must stay one. + @spawn's control here asserted the whole LIVE guard file clean, which + made it a second copy of the ban wearing a control's name — restoring + the walk redded the control too, so it could never have told them the + detector had gone blind. This one cannot fail for the ban's reason: + the string it parses has no call in it at all, so it fails only if the + detector starts convicting prose, which is the single thing it exists + to rule out. + """ + tree = ast.parse('"""Walks sys._getframe rather than inspect.stack() — see the cure."""\n') + assert self._inspect_stack_calls(tree) == [], ( + "a string ban convicts the explanation along with the defect, which is how a cure ends up undocumented" + ) + + def test_the_stack_detector_clears_an_unrelated_stack_attribute(self): + tree = ast.parse("import numpy\nx = numpy.stack([1, 2])\ntraceback = []\ntraceback.stack()\n") + assert self._inspect_stack_calls(tree) == [], "the ban is on inspect.stack, not on the word stack" diff --git a/src/aipass/drone/tests/test_json_dir_seam.py b/src/aipass/drone/tests/test_json_dir_seam.py new file mode 100644 index 000000000..61a9376af --- /dev/null +++ b/src/aipass/drone/tests/test_json_dir_seam.py @@ -0,0 +1,324 @@ +# =================== AIPass ==================== +# Name: test_json_dir_seam.py +# Description: The json_handler test seam resolves at call time +# Version: 1.0.0 +# Created: 2026-08-30 +# ============================================= + +"""drone's own json_handler must honour AIPASS_TEST_LOG_DIR. + +@prax's contract ruling (2026-08-30): AIPASS_TEST_LOG_DIR is the seam, in +@trigger's form, adopted by each branch in its OWN json_handler. Not a sixth +mocking technique — five already existed and every one of them reaches nothing. + +Measured on this tree before any of this was written: the env var was set to +/tmp/aipass_test_logs_xiwxoz8x and JSON_DIR still resolved to the live +drone_json/. That is why the resolution lives behind a function. A value +captured at import cannot be redirected by a conftest that runs afterwards, and +a seam that has to win an import race is not a seam. + +4189 of this branch's 7652 audit-tests hygiene records are these writes. +""" + +from pathlib import Path + +import pytest + +from aipass.drone.apps.handlers.json import json_handler + + +class TestTheSeamResolvesAtCallTime: + """The use site, not just the resolver — @prax's third detail. + + A mutation reverting the path builder to read the import-time constant + survived every other test they had written. + """ + + def test_get_json_path_follows_the_env_var(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + + resolved = json_handler.get_json_path("probe", "log") + + assert tmp_path in resolved.parents, resolved + + def test_the_live_tree_is_not_touched_when_the_var_is_set(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + + resolved = json_handler.get_json_path("probe", "log") + + assert "src/aipass/drone/drone_json" not in str(resolved) + + def test_a_write_lands_in_the_redirected_dir(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + + json_handler.ensure_json_exists("seamprobe", "log") + + assert list(tmp_path.rglob("seamprobe_log.json")), sorted(tmp_path.rglob("*")) + + +class TestAbsenceAndOverride: + def test_an_empty_value_is_absence_not_a_redirect(self, monkeypatch): + """Path('') / 'x' is RELATIVE and scatters state wherever we stand.""" + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", "") + + resolved = json_handler.get_json_path("probe", "log") + + assert resolved.is_absolute() + assert resolved.parent == json_handler._IMPORT_TIME_JSON_DIR + + def test_no_var_at_all_resolves_to_the_real_tree(self, monkeypatch): + monkeypatch.delenv("AIPASS_TEST_LOG_DIR", raising=False) + + resolved = json_handler.get_json_path("probe", "log") + + assert resolved.parent == json_handler._IMPORT_TIME_JSON_DIR + + def test_an_explicit_patch_still_wins_over_the_env_var(self, tmp_path, monkeypatch): + """~20 tests across this suite redirect by setattr. They must keep working.""" + elsewhere = tmp_path / "explicit" + elsewhere.mkdir() + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path / "env")) + monkeypatch.setattr(json_handler, "JSON_DIR", elsewhere) + + resolved = json_handler.get_json_path("probe", "log") + + assert resolved.parent == elsewhere + + def test_a_patch_given_as_a_string_still_wins(self, tmp_path, monkeypatch): + """Several branches' shared tests patch it as a str, not a Path.""" + elsewhere = tmp_path / "explicit" + elsewhere.mkdir() + monkeypatch.setattr(json_handler, "JSON_DIR", str(elsewhere)) + + assert json_handler.get_json_path("probe", "log").parent == elsewhere + + +class TestNoDirectoryIsCreatedInTheLiveTree: + """A mkdir into the real tree is a write, and writes are what we are killing. + + ``ensure_json_exists`` creates the directory and then the file. Reverting + ONLY the mkdir still produced a correct file — something downstream made the + redirected parent — so the file-exists assertion above could not see it. The + audit hook can: os.mkdir on the live drone_json/ is a recorded violation + whether or not the directory was already there. + """ + + def test_the_mkdir_targets_the_redirected_dir_not_the_live_one(self, tmp_path, monkeypatch): + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + targets = [] + real_mkdir = Path.mkdir + + def _spy(self, *args, **kwargs): + targets.append(Path(self)) + return real_mkdir(self, *args, **kwargs) + + monkeypatch.setattr(Path, "mkdir", _spy) + json_handler.ensure_json_exists("mkdirprobe", "log") + + assert targets, "ensure_json_exists stopped creating its directory" + assert json_handler._IMPORT_TIME_JSON_DIR not in targets, f"a directory was created in the live tree: {targets}" + + +class TestNoModuleWritesAtImportTime: + """A write bound to an import is a write no fixture can gate. + + Measured while re-verifying @prax's seam: after AIPASS_TEST_LOG_DIR was + adopted, ONE live-tree file still changed on every run — + ``drone_json/exceptions_log.json``. The cause was not the seam. It was a + module-level ``log_operation`` call in ``apps/handlers/exceptions.py``, + which runs during COLLECTION: before any fixture exists, and therefore + outside the repo-root conftest's autouse guard that protects every other + branch's shared JSON. The seam resolves at call time correctly; this call + simply happened before there was a test to gate it. + """ + + def test_importing_the_exception_hierarchy_writes_nothing(self): + import importlib + + from aipass.drone.apps.handlers.json import json_handler as jh + + calls = [] + real = jh.log_operation + jh.log_operation = lambda *a, **kw: calls.append((a, kw)) or True + try: + importlib.reload(importlib.import_module("aipass.drone.apps.handlers.exceptions")) + finally: + jh.log_operation = real + + assert calls == [], f"import wrote a JSON record no fixture can intercept: {calls}" + + +class TestAnIdenticalValueIsNotAPatch: + """``JSON_DIR`` rebound to the import-time VALUE is not a redirect. + + The first cut of this seam asked ``JSON_DIR is not _IMPORT_TIME_JSON_DIR``. + Identity is the wrong question, and the full suite proved it: a test patches + JSON_DIR to tmp_path, reloads the module — which rebinds BOTH names to fresh + objects — and monkeypatch's undo then restores the pre-reload Path. Equal + value, different object. From that point every later test in the process + took the explicit-patch branch and wrote into the live drone_json: 3757 + resolutions per run, measured, from ordinary tests that never touched + JSON_DIR at all. + + A caller who sets JSON_DIR to exactly where it already pointed has redirected + nothing, so the env var still governs. + """ + + def test_rebinding_to_an_equal_path_still_honours_the_env_var(self, monkeypatch, tmp_path): + from aipass.drone.apps.handlers.json import json_handler as jh + + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + monkeypatch.setattr(jh, "JSON_DIR", Path(str(jh._IMPORT_TIME_JSON_DIR))) + + resolved = jh._current_json_dir() + + assert tmp_path in resolved.parents, f"a same-value rebind hijacked the seam: {resolved}" + + def test_a_real_redirect_is_still_obeyed_over_the_env_var(self, monkeypatch, tmp_path): + """Value comparison must not weaken the explicit patch it exists to honour.""" + from aipass.drone.apps.handlers.json import json_handler as jh + + target = tmp_path / "explicit" + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path / "env")) + monkeypatch.setattr(jh, "JSON_DIR", target) + + assert jh._current_json_dir() == target + + +class TestTheRedirectSurvivesAReloadInEitherOrder: + """@prax's corrected contract, pinned against BOTH reload orderings. + + A test that calls ``importlib.reload`` while a monkeypatch is live has its + teardown write the PRE-reload Path object onto the POST-reload module. The + override test must not mistake that for a deliberate patch — in either + order: env set before the import, or the import first and the env after. + + Run in a subprocess deliberately. Reloading json_handler in-process is the + very thing that broke the seam, and a pin that damages the session it runs + in is not a pin. + """ + + SCRIPT = """ +import pytest +import importlib, os, sys, tempfile + +MOD = "aipass.drone.apps.handlers.json.json_handler" +order = sys.argv[1] +tmp = tempfile.mkdtemp(prefix="ord_") + +if order == "env-first": + os.environ["AIPASS_TEST_LOG_DIR"] = tmp + jh = importlib.reload(importlib.import_module(MOD)) + pre = jh.JSON_DIR + jh = importlib.reload(importlib.import_module(MOD)) +else: + os.environ.pop("AIPASS_TEST_LOG_DIR", None) + jh = importlib.reload(importlib.import_module(MOD)) + pre = jh.JSON_DIR + os.environ["AIPASS_TEST_LOG_DIR"] = tmp + jh = importlib.reload(importlib.import_module(MOD)) + +jh.JSON_DIR = pre # what monkeypatch teardown actually does +print(jh._current_json_dir()) +sys.exit(0 if str(jh._current_json_dir()).startswith(tmp) else 1) +""" + + @pytest.mark.parametrize("order", ["env-first", "import-first"]) + def test_the_redirect_is_still_alive_after_the_reload(self, order): + import subprocess + import sys + + result = subprocess.run( + [sys.executable, "-c", self.SCRIPT, order], + capture_output=True, + text=True, + timeout=60, + ) + + assert result.returncode == 0, ( + f"the redirect died on the {order} ordering — resolved to {result.stdout.strip()}\n{result.stderr}" + ) + + +class TestTheSavePathCreatesItsOwnDirectory: + """A redirect points at a directory that does not exist yet. + + @daemon's second defect, checked here rather than assumed: their save_json + went straight into the atomic write, whose tempfile raises FileNotFoundError + before a byte is written, and it had worked for years only because the live + daemon_json/ is committed and therefore always present. Drone already + mkdirs in _atomic_write_json, so this passed on the first run — pinned so + that stays true, because nothing else would notice it going away until a + redirect or a clean checkout hit it. + """ + + def test_writing_into_a_directory_that_does_not_exist_yet_succeeds(self, tmp_path, monkeypatch): + target = tmp_path / "never" / "created" + monkeypatch.setattr(json_handler, "JSON_DIR", target) + + assert json_handler.save_json("probe", "log", []) is True + assert (target / "probe_log.json").is_file() + + +class TestTheAnchorIsEnvIndependent: + """The precondition this seam rests on, pinned instead of asserted in prose. + + ``_current_json_dir`` treats "differs from BOTH fixed points" as proof of a + deliberate override. That reasoning is only sound while ``real`` really is + the real tree — and ``real`` is ``_IMPORT_TIME_JSON_DIR``, captured once at + import. If that anchor were seeded from ``AIPASS_TEST_LOG_DIR``, then any + run where the variable is already exported at import time makes the ANCHOR a + redirect. A later test pointing the variable somewhere else leaves the stale + anchor differing from both fixed points, which reads as an explicit patch, + and the seam dies for the rest of the process. + + That is not hypothetical and it is not drone's: @prax shipped the contract + and then violated this precondition in their own implementation, took two CI + reds for it (deterministic from the repo root, green from the branch dir), + and named my docstring's "load-bearing" line as the bug report. Their + mechanism needs NO ``importlib.reload`` — an env var already exported at + import is enough, which makes it a wider hole than the reload write-back + @daemon and I both hit. + + Drone is immune BY CONSTRUCTION — the anchor is ``_BRANCH_ROOT / + "drone_json"`` and reads no environment. But "immune by construction" was + exactly the shape of two guards this week that turned out to be unobservable, + so it is a test now. + + IT MUST BE A SUBPROCESS. In-process the property is unfalsifiable: the import + already happened, so setting the variable now proves nothing about what the + anchor was seeded from. @prax's form, adopted. + """ + + def test_importing_with_the_env_var_already_set_leaves_the_anchor_on_the_real_tree(self, tmp_path): + import subprocess + import sys + import textwrap + + probe = textwrap.dedent( + """ + import os, sys + os.environ["AIPASS_TEST_LOG_DIR"] = sys.argv[1] + from aipass.drone.apps.handlers.json import json_handler as jh + print("ANCHOR", jh._IMPORT_TIME_JSON_DIR) + print("REDIRECT", jh._current_json_dir()) + """ + ) + result = subprocess.run( + [sys.executable, "-c", probe, str(tmp_path)], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + anchor = next(line.split(" ", 1)[1] for line in result.stdout.splitlines() if line.startswith("ANCHOR")) + redirect = next(line.split(" ", 1)[1] for line in result.stdout.splitlines() if line.startswith("REDIRECT")) + + # Compared as PATH PARTS, not as a string suffix: Windows CI runs this + # whole suite and separators differ there. A pin that only holds on + # POSIX is the machine-reading species this branch spent the night on. + assert Path(anchor).parts[-2:] == ("drone", "drone_json"), ( + f"the anchor was seeded from the environment: {anchor}" + ) + assert str(tmp_path) not in anchor, f"the anchor IS the redirect — the seam has no fixed point: {anchor}" + assert str(tmp_path) in redirect, f"the env var was exported before import and ignored: {redirect}" diff --git a/src/aipass/drone/tests/test_no_cwd_sweep.py b/src/aipass/drone/tests/test_no_cwd_sweep.py new file mode 100644 index 000000000..99b27147d --- /dev/null +++ b/src/aipass/drone/tests/test_no_cwd_sweep.py @@ -0,0 +1,419 @@ +# =================== AIPass ==================== +# Name: test_no_cwd_sweep.py +# Description: Every location-inference site survives a deleted working directory +# Version: 1.0.0 +# Created: 2026-08-31 +# ============================================= + +"""No location is not a failure — pinned at every site that infers one. + +THE SPECIES. ``Path.cwd()`` raises ENOENT the moment the directory it names is +gone. Session 68 fixed that in the deletion RECORD and session 69 fixed it on +the ROUTING path, and both times the fix landed on the sites already in hand +rather than on every site in the tree. @trigger reproduced the leftover live — +``drone rm`` from a deleted directory, exit 1, ``FileNotFoundError`` at +``rm_handler.py:37`` — and named it as the pattern rather than the incident: a +fix that lands on some of N identical paths. This file is the N. + +It is also the recovery case, which is what makes it more than tidiness. The +first ``drone rm`` succeeds and deletes the directory the caller stands in; the +SECOND command — the one a person runs to clean up after — is the one that +crashed. Fixing the crash in session 68 is what put a live process here to run +it. + +THE RULE, identical at every site: a walk that infers something from where the +caller STANDS has nothing to read when the caller stands nowhere. That is the +absence of a signal, said out loud at INFO, not an error — so the walk is +SKIPPED and every other source still answers. Sites that can honestly return +"unknown" do. The one gate that cannot — owner-tier auth — fails CLOSED and +says why. + +``caller_cwd()`` in ``router_handler`` is the single reader. A tenth private +copy of ``try: Path.cwd() except OSError: None`` would be this file's own +lesson repeated. +""" + +import ast +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + + +@pytest.fixture() +def no_cwd(monkeypatch): + """The process has no working directory — the state, not a mock of a guard. + + Patches ``Path.cwd`` rather than ``caller_cwd`` on purpose: before the + sweep, nine sites never went through ``caller_cwd`` at all, so patching the + guard would have made this file green against unfixed code. + """ + + def gone(): + raise FileNotFoundError(2, "No such file or directory") + + monkeypatch.setattr(Path, "cwd", staticmethod(gone)) + yield + + +@pytest.fixture() +def home_root(tmp_path, monkeypatch): + """An AIPass home that answers when the cwd cannot.""" + home = tmp_path / "home" + home.mkdir() + (home / "AIPASS_REGISTRY.json").write_text('{"branches": []}') + monkeypatch.setenv("AIPASS_HOME", str(home)) + return home + + +# --------------------------------------------------------------------------- +# The entry point +# --------------------------------------------------------------------------- + + +class TestDroneEntryPointSurvives: + def test_registry_presence_check_falls_through_to_aipass_home(self, no_cwd, home_root): + """The cwd walk is skipped; the source that never needed a location answers.""" + from aipass.drone.apps import drone + + assert drone._cwd_has_registry() is True + + def test_registry_presence_check_answers_false_rather_than_raising(self, no_cwd, monkeypatch): + from aipass.drone.apps import drone + + monkeypatch.delenv("AIPASS_HOME", raising=False) + assert drone._cwd_has_registry() is False + + def test_the_seat_inbox_is_unknown_not_a_crash(self, no_cwd): + """A seat is inferred from where you stand. Standing nowhere means no seat.""" + from aipass.drone.apps import drone + + assert drone._find_seat_inbox() is None + + +# --------------------------------------------------------------------------- +# The delete lane — the one @trigger reproduced +# --------------------------------------------------------------------------- + + +class TestTheDeleteLaneSurvives: + def test_project_root_falls_through_to_aipass_home(self, no_cwd, home_root): + from aipass.drone.apps.handlers import rm_handler + + assert rm_handler._find_project_root() == home_root.resolve() + + def test_project_root_is_unknown_rather_than_a_crash(self, no_cwd, monkeypatch): + from aipass.drone.apps.handlers import rm_handler + + monkeypatch.delenv("AIPASS_HOME", raising=False) + assert rm_handler._find_project_root() is None + + def test_the_current_branch_is_unknown(self, no_cwd, tmp_path): + from aipass.drone.apps.handlers import rm_handler + + assert rm_handler._detect_current_branch(tmp_path) is None + + def test_an_absolute_path_still_deletes(self, no_cwd, home_root): + """The recovery command. It names its target absolutely and needs no cwd.""" + from aipass.drone.apps.handlers import rm_handler + + target = home_root / "scratch" + target.mkdir() + + results = rm_handler.safe_delete([str(target)]) + + assert results[0][1] is True, results + assert not target.exists() + + def test_a_relative_path_is_refused_cleanly_not_crashed(self, no_cwd, home_root): + """A relative path is meaningless without a cwd — a refusal, not a traceback.""" + from aipass.drone.apps.handlers import rm_handler + + results = rm_handler.safe_delete(["scratch"]) + + assert results[0][1] is False + assert "current directory" in results[0][2].lower(), results + + +@pytest.mark.deletable_cwd +class TestTheDeleteLaneSurvivesForReal: + """The end-to-end case, in a subprocess with a genuinely deleted directory. + + @trigger ran exactly this and got exit 1 with a traceback. Patched fixtures + prove the guards; only a real deleted directory proves the lane. + """ + + def test_drone_rm_from_a_deleted_directory_reports_instead_of_crashing(self, tmp_path): + stand_in = tmp_path / "standhere" + stand_in.mkdir() + victim = tmp_path / "victim" + victim.mkdir() + + probe = textwrap.dedent( + """ + import os, shutil, sys + here, target = sys.argv[1], sys.argv[2] + os.chdir(here) + shutil.rmtree(here) + from aipass.drone.apps.handlers import rm_handler + results = rm_handler.safe_delete([target]) + print("RESULT", results[0][1], results[0][2]) + """ + ) + result = subprocess.run( + [sys.executable, "-c", probe, str(stand_in), str(victim)], + capture_output=True, + text=True, + cwd=str(tmp_path), + ) + + assert result.returncode == 0, result.stderr + assert "RESULT True" in result.stdout, result.stdout + assert not victim.exists() + + +# --------------------------------------------------------------------------- +# The git lane +# --------------------------------------------------------------------------- + + +class TestTheGitLaneSurvives: + def test_the_caller_branch_is_unknown_rather_than_a_crash(self, no_cwd): + from aipass.drone.apps.modules import git_module + + assert git_module._detect_branch_dir() is None + + def test_the_repo_root_comes_from_aipass_home_when_there_is_no_cwd(self, no_cwd, home_root): + """``find_repo_root`` promises a Path, not an Optional — so it must find one. + + With no cwd both the walk and the toplevel-query fallback lose their + starting point, so the answer comes from the sources that never needed + one. AIPASS_HOME is the first of them, and it is checkable against a + stand-in rather than against this machine. + """ + from aipass.drone.apps.handlers.git import lock_handler + + assert lock_handler.find_repo_root() == home_root + + def test_with_no_registry_findable_anywhere_it_still_returns_a_real_directory(self, no_cwd, monkeypatch): + """The bare-runner world, reproduced — and the assertion this file got wrong. + + The first cut asserted ``list(root.glob("*_REGISTRY.json"))`` — "the + answer is a project root". That reads the MACHINE. ``*_REGISTRY.json`` + is gitignored and machine-local, so a clean checkout has none, and it + red on CI with "/home/runner/work/AIPass/AIPass is not a project root" + — which was the honest answer to a question nobody should have asked. + The half-present world this file's own sweep was written about, committed + in the one test the sweep added. @devpulse caught it on PR#750. + + The registry walk is switched off here rather than assumed absent, so + the marker leg — the only leg a bare runner reaches — actually executes. + + WHAT IS DURABLE, and true in both worlds: with no cwd the answer is a + real absolute directory that CONTAINS this package. Never a relative + sentinel, never the deleted directory, never a raise. Whether that + directory happens to hold a registry is a fact about the checkout, not + about the function. + """ + from aipass.drone.apps.handlers.git import lock_handler + + real_glob = Path.glob + + def no_registries(self, pattern, *args, **kwargs): + if pattern == "*_REGISTRY.json": + return iter(()) + return real_glob(self, pattern, *args, **kwargs) + + monkeypatch.setattr(Path, "glob", no_registries) + monkeypatch.delenv("AIPASS_HOME", raising=False) + + root = lock_handler.find_repo_root() + + assert isinstance(root, Path) + assert root.is_absolute(), f"a relative answer names nowhere in particular: {root}" + assert root.is_dir(), f"a lock cannot be written into {root}" + assert Path(lock_handler.__file__).resolve().is_relative_to(root), ( + f"{root} does not contain the package that answered from it" + ) + # "Contains the package" alone is too weak — apps/handlers/git/ satisfies + # it, and that is the last-resort return. A project root is a directory + # that DECLARES itself one, and a bare checkout still has .git. + assert any((root / marker).exists() for marker in lock_handler._PROJECT_MARKERS), ( + f"{root} carries no project marker — a lock would land in a subdirectory" + ) + + +class TestTheAuthGateFailsClosed: + """The one site that must NOT answer "unknown". + + Every other site here infers a convenience. This one decides whether a + caller may write to the repository, and "I could not tell who you are" is a + refusal — the same answer it already gives when the walk finds no passport. + What changes is that it arrives as a stated refusal instead of an ENOENT + traceback from inside a credential check. + """ + + def test_no_cwd_is_a_refusal_that_says_why(self, no_cwd): + from aipass.drone.apps.plugins.devpulse_ops import auth + + with pytest.raises(PermissionError) as excinfo: + auth._resolve_caller() + + assert "no current directory" in str(excinfo.value).lower(), str(excinfo.value) + + +class TestTheBrokerSurvives: + def test_broker_project_root_falls_through_to_aipass_home(self, no_cwd, home_root): + from aipass.drone.apps.handlers.broker import daemon + + assert daemon._find_project_root() == home_root.resolve() + + def test_broker_project_root_is_unknown_rather_than_a_crash(self, no_cwd, monkeypatch): + from aipass.drone.apps.handlers.broker import daemon + + monkeypatch.delenv("AIPASS_HOME", raising=False) + assert daemon._find_project_root() is None + + +class TestTheSweepIsComplete: + """The count is the finding — three times a list of these sites came in low. + + Session 69 reported two unguarded reads where there were three. @trigger + corrected "exactly one in the whole tree" to nine, and their nine was itself + short of the ten actually there (``broker/daemon.py`` and + ``git/lock_handler.py`` were not on their list). A prose count is what keeps + being wrong, so the count is a test. + + THE INSTRUMENT CHANGED 2026-08-31, and the reason is worth keeping. This + was a line scan that stripped ``#`` comments and matched the text + ``Path.cwd()`` / ``os.getcwd()``. It went red on the Windows round-4 build + against two DOCSTRINGS — prose in ``handlers/__init__.py`` and + ``handlers/module_root.py`` explaining that ``ntpath.realpath`` reads + ``os.getcwd()`` unconditionally, which is why those files were cured. A + string ban convicts the explanation along with the defect, which is how a + cure ends up undocumented. It parses now, so the ban is on the CALL. + + The parse also closed a hole the text match had: ``from os import getcwd`` + followed by a bare ``getcwd()`` was invisible to it, and is not now. + """ + + @staticmethod + def _bare_cwd_reads(tree: ast.Module) -> list[int]: + """Lines calling ``Path.cwd()``, ``os.getcwd()``, or a bare ``getcwd()``.""" + found = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name): + if (func.value.id, func.attr) in {("Path", "cwd"), ("os", "getcwd")}: + found.append(node.lineno) + elif isinstance(func, ast.Name) and func.id == "getcwd": + found.append(node.lineno) + return found + + def test_no_bare_cwd_read_survives_outside_caller_cwd(self): + import aipass.drone.apps as drone_apps + + root = Path(drone_apps.__file__).parent + offenders = [] + for source in sorted(root.rglob("*.py")): + if source.name == "router_handler.py": + continue # caller_cwd() itself — the one sanctioned read + tree = ast.parse(source.read_text(encoding="utf-8")) + for lineno in self._bare_cwd_reads(tree): + offenders.append(f"{source.relative_to(root)}:{lineno}") + + assert offenders == [], "bare working-directory reads outside caller_cwd(): " + ", ".join(offenders) + + def test_the_detector_convicts_each_spelling_it_bans(self): + """Both directions, because a checker that convicts nothing reads green.""" + assert self._bare_cwd_reads(ast.parse("from pathlib import Path\nhere = Path.cwd()\n")) + assert self._bare_cwd_reads(ast.parse("import os\nhere = os.getcwd()\n")) + assert self._bare_cwd_reads(ast.parse("from os import getcwd\nhere = getcwd()\n")), ( + "the bare-import spelling was invisible to the line scan this replaced" + ) + + def test_the_detector_clears_prose_that_names_the_defect(self): + """The red that changed the instrument, kept as the pin for it.""" + tree = ast.parse('"""ntpath.realpath reads os.getcwd() unconditionally — hence the guard."""\n') + assert self._bare_cwd_reads(tree) == [], ( + "a docstring explaining the cure is not the defect; convicting it is how cures go unexplained" + ) + + +class TestTheWindowsSkipIsNarrow: + """A skip that fires everywhere reports its own defeat as a pass. + + Nine tests in this branch build their world by deleting the directory they + stand in, and Windows will not let a process do that (conftest's + WINDOWS_CWD_REASON states the ruling: the recipe is unavailable there, not + the state). They carry ``@pytest.mark.deletable_cwd`` and conftest skips + them on win32 only. + + "Only" is the whole load-bearing word. A condition that quietly became true + everywhere would turn nine red tests into nine green ones and read the same + in the summary line — @memory found exactly that mutant surviving in their + own tree, which is why this is measured rather than asserted about the + marker object. + """ + + MARKED = ( + "tests/test_deletion_log.py::TestRecordFailureIsContained::test_record_deletion_does_not_raise_when_cwd_is_gone" + ) + + def test_a_marked_test_runs_on_every_platform_but_windows(self): + import aipass.drone.apps as drone_apps + + branch_root = Path(drone_apps.__file__).resolve().parent.parent + result = subprocess.run( + [sys.executable, "-m", "pytest", self.MARKED, "-q", "-rs", "--timeout=120", "-p", "no:randomly"], + cwd=str(branch_root), + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stdout + result.stderr + if sys.platform == "win32": + assert "1 skipped" in result.stdout, result.stdout + assert "current directory" in result.stdout, "the skip did not say why" + else: + assert "1 passed" in result.stdout, result.stdout + assert "skipped" not in result.stdout, ( + "a deletable-cwd test was skipped on a platform that can delete its own cwd: " + result.stdout + ) + + def test_the_hook_skips_marked_items_on_windows_and_only_there(self, monkeypatch): + """The Windows half of the ruling, checked from a machine that is not Windows. + + The subprocess pin above can only observe THIS platform, so a + ``pytest_collection_modifyitems`` that stopped matching the marker + entirely would leave it green here and hand Windows nine reds back. + The hook is a function; called directly with the platform it branches + on, both of its answers are observable anywhere. + """ + from types import SimpleNamespace + + from aipass.drone.tests import conftest as drone_conftest + + def one_item(): + recorded = [] + return SimpleNamespace( + keywords={drone_conftest.DELETABLE_CWD_MARKER: True}, + recorded=recorded, + add_marker=recorded.append, + ) + + monkeypatch.setattr(drone_conftest.sys, "platform", "win32") + on_windows = one_item() + drone_conftest.pytest_collection_modifyitems(None, [on_windows]) + assert on_windows.recorded, "a deletable-cwd test was left to run on Windows" + assert drone_conftest.WINDOWS_CWD_REASON in str(on_windows.recorded[0]) + + monkeypatch.setattr(drone_conftest.sys, "platform", "linux") + elsewhere = one_item() + unmarked = SimpleNamespace(keywords={}, recorded=[], add_marker=lambda m: None) + drone_conftest.pytest_collection_modifyitems(None, [elsewhere, unmarked]) + assert not elsewhere.recorded, "the skip reached a platform that can delete its own cwd" diff --git a/src/aipass/drone/tests/test_registry_case_sweep.py b/src/aipass/drone/tests/test_registry_case_sweep.py new file mode 100644 index 000000000..d37681805 --- /dev/null +++ b/src/aipass/drone/tests/test_registry_case_sweep.py @@ -0,0 +1,199 @@ +# =================== AIPass ==================== +# Name: test_registry_case_sweep.py +# Description: A case-insensitive filesystem must not widen what counts as a registry +# Version: 1.0.0 +# Created: 2026-08-31 +# ============================================= + +"""``*_REGISTRY.json`` is a name, not a spelling the filesystem gets to choose. + +THE DEFECT. ``Path.glob`` asks the FILESYSTEM to match. On a case-insensitive +one — Windows, and macOS by default — ``*_REGISTRY.json`` also matches +``*_registry.json``, and this repository is full of files with that ending: +``drone_command_registry.json`` beside the drone package, ten +``flow_json/*_registry.json`` plan counters, a ``.spawn/.template_registry.json`` +in every branch (pathlib's ``*`` matches dotfiles, unlike the ``glob`` module). +Windows CI found it as a test red — ``find_registry()`` returned +``D:/a/AIPass/AIPass/src/aipass/drone/drone_command_registry.json`` — and the +red was the smaller half of it. + +WHY IT IS NOT COSMETIC. A ``*_REGISTRY.json`` is a project's trust anchor. The +walks in this tree use it to answer "which installation is this caller a citizen +of", "what project name goes on their identity", "where is the project root the +delete lane may write a record into". A plan-id counter answering those is not a +near miss; it is a different question. And every walk in the tree carried its +own copy of the glob, so the fix had to land on all of them at once — the same +species as the dead-cwd sweep, which is why this file is shaped like that one. + +THE INSTRUMENT. A case-insensitive filesystem is not available on the Linux box +this was fixed on, so the fixture below supplies exactly what one returns: the +directory listing, matched with ``re.IGNORECASE``. That is the state, not a mock +of the guard — ``registries_in`` is never patched, and the pins run red against +the unfixed code on every OS rather than only on the runner where it happened to +show. +""" + +import fnmatch +import re +from pathlib import Path + +import pytest + +from aipass.drone.apps.handlers import registry_handler +from aipass.drone.apps.handlers.router_handler import _REGISTRY_SUFFIX, registries_in + +# A name that ends with the suffix in the wrong case. Real, tracked, and sitting +# in this branch's own directory — see TestTheDecoyIsLive. +DECOY_NAME = "drone_command_registry.json" + + +@pytest.fixture() +def case_insensitive_filesystem(monkeypatch): + """``Path.glob`` folds case, the way NTFS and default APFS do. + + Only single-level patterns are folded — everything with a separator or a + ``**`` is handed back to the real implementation untouched, because this + fixture is modelling one filesystem property and not reimplementing glob. + + Real ``Path.glob`` swallows an unreadable directory; this deliberately does + not. A walk that cannot read a directory it passes is something a test + should be told about, not something an instrument should hide. + """ + real_glob = Path.glob + + def folding_glob(self, pattern, *args, **kwargs): + if "/" in pattern or "\\" in pattern or "**" in pattern: + return real_glob(self, pattern, *args, **kwargs) + if not self.is_dir(): + return iter(()) + matcher = re.compile(fnmatch.translate(pattern), re.IGNORECASE) + return iter([p for p in sorted(self.iterdir()) if matcher.match(p.name)]) + + monkeypatch.setattr(Path, "glob", folding_glob) + return folding_glob + + +@pytest.fixture() +def no_cwd(monkeypatch): + """The state Windows CI was in when it hit this: the walk starts from the package.""" + + def gone(): + raise FileNotFoundError(2, "No such file or directory") + + monkeypatch.setattr(Path, "cwd", staticmethod(gone)) + yield + + +class TestTheFilterIsInPython: + """``str.endswith`` is case-sensitive on every platform. The glob is not.""" + + def test_a_wrong_case_name_is_not_a_registry(self, tmp_path, case_insensitive_filesystem): + (tmp_path / DECOY_NAME).write_text("{}") + + assert registries_in(tmp_path) == [], ( + "a lowercase-suffixed file was served as a registry — on a case-insensitive " + "filesystem the glob matched it and nothing checked the name afterwards" + ) + + def test_an_exact_case_registry_still_resolves(self, tmp_path, case_insensitive_filesystem): + """The positive control. A filter that drops everything passes the test above.""" + real = tmp_path / "AIPASS_REGISTRY.json" + real.write_text("{}") + (tmp_path / DECOY_NAME).write_text("{}") + + assert registries_in(tmp_path) == [real] + + def test_the_stem_case_is_not_constrained(self, tmp_path, case_insensitive_filesystem): + """Only the SUFFIX is the convention. + + External projects name the registry after themselves and nothing + promises the project name is uppercase — matching on the whole filename + would fence out the citizens the glob was widened for in the first place. + """ + odd = tmp_path / f"vera-studio{_REGISTRY_SUFFIX}" + odd.write_text("{}") + + assert registries_in(tmp_path) == [odd] + + def test_the_answer_is_sorted(self, tmp_path): + """Two registries in one directory must resolve the same way every run.""" + for name in ("ZULU_REGISTRY.json", "ALPHA_REGISTRY.json"): + (tmp_path / name).write_text("{}") + + assert [p.name for p in registries_in(tmp_path)] == ["ALPHA_REGISTRY.json", "ZULU_REGISTRY.json"] + + def test_an_absent_directory_is_empty_not_an_error(self, tmp_path): + assert registries_in(tmp_path / "nope") == [] + + +class TestTheDecoyIsLive: + """This is not a hypothetical filesystem property — the bait is checked in. + + If this ever fails because the file was renamed, the filter below it is + still right; what changed is that the tree stopped demonstrating why. + """ + + def test_the_branch_ships_a_wrong_case_registry_name(self): + import aipass.drone.apps as drone_apps + + branch_root = Path(drone_apps.__file__).resolve().parent.parent + decoy = branch_root / DECOY_NAME + + assert decoy.is_file(), f"expected the tracked decoy at {decoy}" + assert not decoy.name.endswith(_REGISTRY_SUFFIX) + assert decoy.name.lower().endswith(_REGISTRY_SUFFIX.lower()), ( + "the decoy no longer ends with the suffix in any case — it is not bait any more" + ) + + +class TestTheWindowsRedIsReproduced: + """The exact CI failure, on Linux, with the filesystem property supplied. + + ``test_registry_handler.py::test_the_walk_that_calls_it_reaches_it_at_all`` + failed on Windows because with no cwd the walk starts at this package and + climbs — and the first directory it passes is the branch root, which holds + the decoy. Nothing about that depends on the operating system except whether + the glob matched, so nothing about the pin has to either. + """ + + def test_find_registry_never_returns_a_wrong_case_name(self, no_cwd, case_insensitive_filesystem, monkeypatch): + monkeypatch.delenv("AIPASS_REGISTRY", raising=False) + registry_handler.reset_registry_path() + try: + found = registry_handler.find_registry() + finally: + registry_handler.reset_registry_path() + + assert found.name.endswith(_REGISTRY_SUFFIX), ( + f"find_registry served {found} — a case-insensitive filesystem widened the walk" + ) + + +class TestTheSweepIsComplete: + """One reader. The count is a test, for the same reason it is in the cwd sweep. + + Every walk in this tree carried its own ``glob("*_REGISTRY.json")``: the + entry point, the delete lane, the deletion record, the broker, the git lock, + the registry resolver, and the caller-identity fallback. Fixing the one + Windows named would have left six. + """ + + _CALL = re.compile(r"r?glob\(\s*f?[\"'][^\"']*_registry\.json", re.IGNORECASE) + + def test_no_walk_globs_for_a_registry_outside_the_one_reader(self): + import aipass.drone.apps as drone_apps + + root = Path(drone_apps.__file__).parent + offenders = [] + for source in sorted(root.rglob("*.py")): + if source.name == "router_handler.py": + continue # registries_in() itself — the one sanctioned glob + for number, line in enumerate(source.read_text(encoding="utf-8").splitlines(), 1): + code = line.split("#", 1)[0] + if self._CALL.search(code): + offenders.append(f"{source.relative_to(root)}:{number}") + + assert offenders == [], ( + "registry globs outside registries_in() — each one is case-widened on " + "Windows and macOS: " + ", ".join(offenders) + ) diff --git a/src/aipass/drone/tests/test_registry_handler.py b/src/aipass/drone/tests/test_registry_handler.py index 0c4d600c3..ce12b0503 100644 --- a/src/aipass/drone/tests/test_registry_handler.py +++ b/src/aipass/drone/tests/test_registry_handler.py @@ -17,6 +17,7 @@ import pytest +from aipass.drone.apps.handlers import registry_handler from aipass.drone.apps.handlers.registry_handler import ( _first_registry_in, _registry_matches_credential, @@ -461,6 +462,81 @@ def test_returns_true_when_no_passport(self, registry_dir: Path, monkeypatch): assert _registry_matches_credential(path) is True +class TestCredentialCheckWithNoCurrentDirectory: + """A process whose cwd was deleted has no location, and that is not an error. + + The passport walk starts at the cwd, so ``Path.cwd()`` raising ENOENT used + to land in the broad ``except`` — which logs "Credential pre-check FAILED" + at WARNING and returns True. The answer was right by accident and the + account of it was wrong: nothing failed, there is simply no location to + infer from, which is the documented no-passport case. @daemon named this + one as the member of the cwd family where the cwd is not the interesting + part — a check that reports failure while passing is the fail-open shape + even when the value it returns is correct. + """ + + @staticmethod + def _no_cwd(monkeypatch): + def _raise(): + raise FileNotFoundError(2, "No such file or directory") + + monkeypatch.setattr(Path, "cwd", staticmethod(_raise)) + + def test_a_deleted_cwd_is_not_reported_as_a_failed_check(self, registry_dir: Path, monkeypatch): + path = _write_registry(registry_dir, _minimal_registry(metadata_id="some-id")) + self._no_cwd(monkeypatch) + + with patch.object(registry_handler, "logger") as log: + result = _registry_matches_credential(path) + + assert result is True + failures = [c for c in log.warning.call_args_list if "pre-check failed" in str(c).lower()] + assert not failures, f"an absent location was reported as a failed check: {failures}" + + def test_the_absent_location_is_said_out_loud(self, registry_dir: Path, monkeypatch): + """Silence would make it indistinguishable from a walk that found nothing.""" + path = _write_registry(registry_dir, _minimal_registry(metadata_id="some-id")) + self._no_cwd(monkeypatch) + + with patch.object(registry_handler, "logger") as log: + _registry_matches_credential(path) + + assert log.info.called, "no line records that the check had no location to stand in" + + def test_an_unreadable_registry_is_still_a_warning(self, registry_dir: Path, monkeypatch): + """The broad except keeps its job — only the cwd left through it.""" + monkeypatch.chdir(registry_dir) + + with patch.object(registry_handler, "logger") as log: + result = _registry_matches_credential(registry_dir) + + assert result is True + assert log.warning.called, "an unreadable registry must still be reported" + + def test_the_walk_that_calls_it_reaches_it_at_all(self, monkeypatch): + """The credential fix is worthless if its CALLER raises first. + + ``find_registry`` opens with its own bare ``Path.cwd()``. Guarding only + the check below it would move the ENOENT up one frame and call the lane + fixed — the same shape as a flagged list that undercounts. With no + location the cwd walk is skipped, not attempted: resolution falls + through to AIPASS_HOME and the package walk, which never needed a cwd. + """ + self._no_cwd(monkeypatch) + + assert find_registry().name.endswith("_REGISTRY.json") + + def test_verification_does_not_report_a_failure_it_did_not_have(self, monkeypatch): + """Third site, same species: ``_verify_registry_credential``.""" + self._no_cwd(monkeypatch) + + with patch.object(registry_handler, "logger") as log: + _verify_registry_credential(Path("/nowhere/AIPASS_REGISTRY.json"), {"metadata": {"id": "some-id"}}) + + failures = [c for c in log.warning.call_args_list if "verification failed" in str(c).lower()] + assert not failures, f"an absent location was reported as a failed verification: {failures}" + + # =================================================================== # 8, 9, 10. _verify_registry_credential() # =================================================================== diff --git a/src/aipass/flow/README.md b/src/aipass/flow/README.md index 39c42fec4..ad1d15fc0 100644 --- a/src/aipass/flow/README.md +++ b/src/aipass/flow/README.md @@ -4,9 +4,9 @@ **Purpose:** Unified plan lifecycle management for AIPass. Creates, tracks, closes, and archives numbered work plans across multiple plan types via a filesystem-driven template registry. **Module:** `aipass.flow` -**Version:** 2.2.1 +**Version:** 2.6.0 **Created:** 2025-11-15 -**Last Updated:** 2026-08-25 +**Last Updated:** 2026-08-31 --- @@ -113,6 +113,7 @@ flow/ │ │ ├── post_close_runner.py # Background post-processing with lock management │ │ └── template_manager.py # Template registry management │ └── handlers/ # Implementation details +│ ├── repo_root.py # module_file / find_repo_root / exists_exactly — the one location answer │ ├── plan/ # Lifecycle: create, close, list, restore, display, validation, project scope │ ├── cli/ # Shared --help flag detection (help_flags.py) │ ├── registry/ # Load, save, auto-heal registries @@ -134,7 +135,7 @@ flow/ │ ├── playbook_plans/ # PPLAN templates (SOPs: merge, weekly_update, …) │ └── capture_plans/ # CPLAN templates (default) ├── flow_json/ # Per-type registries + template_registry.json -├── tests/ # 950 tests across 27 files +├── tests/ # 1013 tests across 31 files └── .archive/ # Archived legacy code + orphaned registries ``` @@ -229,6 +230,210 @@ time. --- +## Location Discovery — `handlers/repo_root.py` + +**Nothing in flow reads the process working directory to find itself.** One +module answers both location questions, and every caller routes through it. + +| Function | Answers | Guard it carries | +|----------|---------|------------------| +| `module_file(__file__)` | where is *this module* | `.resolve()` attempted, falls back to the absolute spelling | +| `find_repo_root(start=None)` | which repo root is this | falls back to `SOURCE_ROOT`, **never** `Path.cwd()` | +| `exists_exactly(path)` | is this filename spelled exactly so | lists the parent; `exists()` alone folds case | +| `exactly_named(paths, suffix)` | post-filter for a cased glob | a glob is case-blind on Windows/macOS | + +Built 2026-08-31 (Windows round 4, @memory's finding via @devpulse). +`ntpath.realpath` reads `os.getcwd()` **unconditionally** — before it checks +whether the path is even absolute, where `posixpath` only reads it for a +relative one — and `Path.resolve()` routes through it. So on Windows every +module-level `Path(__file__).resolve()` is an import-time working-directory +dependency: a process whose cwd is gone cannot import the module. Guarding +inside the module's functions changes nothing, because the import died before +any of them existed. + +Measured red-first, in a subprocess, before any cure: **61 of 61 flow modules +died on import** in both injected worlds. The count only became true as cures +landed, because the first crash line masks everything under it — 61 → 43 after +the guard, → 0 after the 29 module-level sites. Flow carried: + +- **29** `_PKG_ROOT = Path(__file__).resolve().parents[N]` sites — every module, + nearly every handler +- **7** private `_find_repo_root` copies, each ending `return Path.cwd()`, **6 + of them called at module level** +- **2** `inspect.stack()` calls — the import guard, and `log_operation`'s + caller detection (which no import probe reaches; it runs at write time) + +The `Path.cwd()` fallback carried two defects, and only the loud one was a +crash. The quiet one: cwd is a *guess*. Four of those seven callers are +**writers** — `push_central`/`aggregate_central` build +`.ai_central/PLANS.central.json`, `close_helpers` and `restore_ops` build the +`.backup/processed_plans` path — and on a registry-less checkout (the core +registry is gitignored, so every clean clone and CI runner qualifies) each one +resolved against whatever directory the caller's shell happened to be in. A +`try`/`except` would have fixed the traceback and kept the wrong answer. + +**Two `Path.cwd()` reads remain, and both are correct.** +`project_scope.caller_cwd()` and `resolve_location._get_caller_cwd()` ask +*where did the caller stand* — a location, observed — and fall back to the +process directory only when `AIPASS_CALLER_CWD` is absent. They are named in +`tests/test_import_dead_cwd.py` so the ban can never delete a right answer, and +the exemption is keyed on **(file, function)**, not on the name alone. + +Enforced by `tests/test_import_dead_cwd.py`: two injected worlds with their own +liveness controls, an AST ban, **and** a behavioural sibling that reaches the +caller-is-None branch by calling the guard directly from a `python -c` child +under a realpath denial. Both instruments are kept: regrowing the deleted walk +kills both, and each catches what the other cannot — the ban names the offending +line anywhere in the tree with no subprocess, the behavioural pin proves the cure +in the world it was built for. (The round-4 guidance said only an AST ban could +watch that branch; @spawn measured the correction — it is unreachable from +*import-shaped* pins, not unreachable.) + +### The bare-checkout world is a tested world + +`AIPASS_REGISTRY.json` is **gitignored and machine-local**, so a dev box and a CI +runner disagree about whether `find_repo_root` takes its fallback. That is not a +detail — it is a whole second world flow's tests must pass in, and it is where +round 5's CI red came from. + +The fallback logs `repo_root_fallback` through `json_handler.log_operation`, and +six modules take the walk while **loading**. On a bare checkout that import-time +diagnostic lands inside whichever test window triggers the first import, where +the autouse `mock_json_handler` counts it — so a test pinning +`assert_called_once` breaks on CI and passes everywhere else. + +`tests/conftest.py` therefore **pre-imports all six module-level callers**, +settling the walk before any test window exists on every machine. The list is +guarded by `TestThePreImportListIsComplete`, which measures the callers off the +tree by parse and compares them against what conftest actually imports — neither +side hand-copied, because a hand-written list is exactly where an undercount +hides. + +Measured, not assumed: with the marker denied and every count-asserting test run +in **full isolation**, **2 of 10** failed. CI had named one. + +### An instrument must not import behaviour it is not testing + +Round 7's Windows red, and it is the sharper half of the lesson above. The +accessor probes captured the live `os.path.realpath`, then asked *"did a later +patch reach it?"* by denying `os.getcwd` and reading raise/no-raise. That +discriminates on posix, where the captured function ignores the cwd for an +absolute path — so a raise can only mean the patch landed. On nt `os.path` **is** +`ntpath`, and `ntpath.realpath` reads `os.getcwd` unconditionally, so the +**original** raises too and *raised* stops meaning *reached*. + +Three rules came out of it (@memory measured the same species on four of their +own reds; each is verified here rather than imported on their word): + +1. **Emulate both platforms or neither.** A table with one emulated row and one + bare row is host-dependent in the half nobody thought about — "no emulation" + reads as posix only while the host is posix. +2. **Build an emulation from the dialect module by name** (`posixpath`, + `ntpath`), never from `os.path`, which *is* the host. +3. **When a probe asks "did my patch reach X", let X have captured a sentinel.** + Otherwise the original's own platform behaviour answers the question. + +The litmus that finds all three: run each probe under the *opposite* platform's +emulation and require the verdict not to move. `TestTheWorldArmsOnAPreCapturedAccessor` +runs it on every direction, with a control pinning that the two emulated hosts +are genuinely different worlds — two identical hosts would pass the litmus for +free. + +### An instrument's INPUTS are behaviour too + +Round 8's Windows red, and it is the round-7 rule one level along. The +emulations were built from `posixpath`/`ntpath` by name and were correct — but +the probe *path* was still built from `os.sep` and `pathlib.__file__`, which are +the **runner's**. On the Windows runner that yields `\definitely\not\here`, +which `posixpath` reads as **relative**; `posixpath.realpath` reads the cwd for a +relative path on every platform, so the posix row convicted for the path's shape +and announced *"the posix emulation is not posix-shaped"* about an emulation that +was doing its job. + +Each host now publishes its own dialect-absolute literal, pinned with `isabs` +from the dialect module by name. **The table is not symmetric**, and saying so +matters: `posixpath` refuses an nt literal, while `ntpath` *accepts* a posix one +and treats it as drive-relative — so an nt probe path must carry a drive, and +`isabs` alone is not enough. + +The missing instrument was a second dimension. Round 7's litmus varied the +emulated **host**; nothing varied the **runner**. `WINDOWS_RUNNER` fakes exactly +what a probe can read to construct a path — `os.sep`, `os.path`, +`pathlib.__file__` — and every direction must return the same verdict with and +without it. Reverting the dialect-absolute literals reds it on Linux, which is +the point: the failure was otherwise only observable on hardware nobody here has. + +### Host == faked is one layer + +Round 9, named by @seedgo. Round 8 shipped `WINDOWS_RUNNER`, a fake of everything +a probe can read to build a path. It works on Linux — and it is **dark on the +runner it was written for**. On a Windows host the fake installs Windows-shaped +values over Windows-shaped values, so the faked and unfaked runs are +byte-identical and the control that requires them to *differ* can never arm. A +single fake cannot arm on the host it imitates, for the same reason a +`nt`-emulating world proves nothing on `nt`. + +What the campaign reported was that control going dark. The bigger hole was +behind it: the **litmus** was dark too. Round 8's check varied the emulated host +only, so on Windows it measured nothing at all — *a one-dimension litmus is blind +on the host that already IS that dimension.* + +The cure is a **set**, not a fake: `RUNNERS` carries a posix-shaped and a +windows-shaped runner, every direction runs under both, and the verdict must not +move. Whichever one matches the host is inert there — and it says so, in +@spawn's three-state shape: **CHANGED / ALREADY-with-the-reason / +UNAVAILABLE-with-the-child's-own-reason.** A row that cannot arm here reports +*why*; it never passes quietly and never fails on someone else's platform. One +pin then simulates *both kinds of host* so the Windows leg is falsifiable from +Linux, forever. + +Two rules ride along. Each fake must override **every** host read a probe makes, +pinned per attribute — a partial fake (`os.sep` without `os.path`) survived +otherwise. And the probe path itself must be a **published literal**: anything +computed from the running host — `os.sep`, `sys.executable`, `__file__` — is the +round-8 defect returning under a new spelling, so the source is parsed and a +non-`Constant` right-hand side is refused by name (@trigger's shape, and their +own round-8 red). + +### A sentinel cannot arm, so it takes the eagerness pin dark + +@trigger's correction, adopted. A sentinel is stale-proof by construction, so a +lazy wrapper *around* the sentinel returns exactly what an eager capture of it +returns — and the behavioural eagerness pin answered the same either way +(measured: that mutation left it green). An identity check cannot be satisfied by +accident, but it **can go dark when the thing whose identity you are checking +stops being able to differ.** + +The durable form is a difference you *construct*: two distinguishable sentinels +and a source name rebound after the class body runs. An eager capture answers +`CAPTURED`, a lazy one follows the name and answers `MOVED` — return-value, with +no filesystem, cwd or path dialect anywhere in the question. + +### An injected world has to ARM, and the arming is version-shaped + +The dead-cwd pins install their world by patching `os.path.realpath` in a +subprocess. On **Python 3.10 that patch reaches nothing**: `pathlib` still has +`_NormalAccessor`, whose `realpath = staticmethod(os.path.realpath)` +(`Lib/pathlib.py:358`) takes its copy when `pathlib` is first imported, and +`Path.resolve` reads it as `self._accessor.realpath(self, strict=strict)` +(`:1077`). Patching the module attribute afterwards rebinds a name nothing will +read again. 3.11 removed the accessor and calls `os.path.realpath` at use — +which is why one CI leg reddened and three stayed green. + +Both worlds therefore end in `_ACCESSOR_CURE`, which patches the accessor as a +`staticmethod` behind a `hasattr` — order-independent on 3.10, inert on 3.11+. +A plain function would arrive **bound** and eat the path into `self`. + +Because only 3.12 exists on a dev box, that cure would otherwise be a row nobody +here could contradict. So `TestTheWorldArmsOnAPreCapturedAccessor` **rebuilds +3.10's construction locally** and pins both directions — bare module patch → +`ACCESSOR_DIES: NO` (CI's failure, reproduced), plus the cure → +`ACCESSOR_DIES: YES` — with the three traps that make the shape vacuously green +(lazy capture, class-level read, relative probe path) pinned as their own +controls. + +--- + ## Close Pipeline On `drone @flow close` — the console prints five numbered steps, with vector @@ -353,12 +558,13 @@ aggregation untouched, plus anything auto-closed during the run. ## Quality -- **Seedgo:** 100% (46 standards, 44 files, no type errors) -- **Tests:** 950 tests in 27 files — 969 cases collected after parametrisation, 968 pass / 1 skip. 98/98 public functions tested (100%, `drone @seedgo test_map @flow`) -- **Source files:** 44 tracked by seedgo (61 `.py` files under `apps/` in total; seedgo excludes `__init__.py` markers) +- **Seedgo:** 100% (46 standards, no type errors) +- **Tests:** 1013 tests in 31 files — 1042 cases collected after parametrisation, 1041 pass / 1 skip, from BOTH rootdirs (branch `pytest.ini` and `-c pyproject.toml --rootdir=.`) AND in both marker worlds (registry present, and a bare checkout like CI's). 101/102 public functions tested (`drone @seedgo test_map @flow`) +- **Source files:** 45 tracked by seedgo (62 `.py` files under `apps/` in total; seedgo excludes `__init__.py` markers) - **Bypass rules:** 59 (74 before the 2026-08-13 audit — 15 dead + 1 false-reason removed) -- **Registries:** 7 registered plan types + 1 orphan; **798 plans on disk, 23 open, 775 closed** -- **Last audit:** 2026-08-25 (every figure on this list re-measured, not carried forward) +- **Registries:** 7 registered plan types + 1 orphan; **820 plans on disk, 24 open, 796 closed** +- **Dead-cwd:** 0 of 62 modules die on import with no readable working directory, in both injected worlds (`tests/test_import_dead_cwd.py`) +- **Last audit:** 2026-08-31 (every figure on this list re-measured, not carried forward) ### Known Issues - **315 of 775 closed plans have no archived copy and cannot be restored.** @@ -385,7 +591,30 @@ aggregation untouched, plus anything auto-closed during the run. `get_status_impl` calls a bare `load_registry()`. Its quarantine list and `Ignored folders: 33` are branch-wide and correct; only the two totals are scoped to one type. -- **`flow_json/PLAN_REGISTRY.json` is legacy but NOT unread.** No flow code +- **The cross-branch handler fence never arms.** `apps/__init__.py` is + `from . import handlers`, so importing *anything* under `aipass.flow.apps` + loads the handlers package first, and at that moment the nearest real-file + frame is `apps/__init__.py` itself — which lives under `/flow/` and is + therefore allowed. An external branch reaching for a flow handler passes the + guard every time. Reported, deliberately NOT fixed: Patrick's fleet ruling + (2026-08-31) is that this is one change made everywhere at once, not + per-branch. Flow has exactly ONE such pre-import door — `flow/__init__.py` is + a bare docstring and there is no public API surface re-exporting handlers. +- **The fence's branch check is a substring, not a path segment.** `MY_BRANCH` + is `"flow"` and the test is `"/flow/" in caller_file`, so any caller whose + path contains a directory named `flow` — in any repo, at any depth — reads as + local. @spawn's cured guard uses the full dotted package (`aipass.spawn` → + `/aipass/spawn/`). Out of scope for the round-4 dispatch (which is about the + cwd defect, not the matching rule) and reported rather than changed, because + narrowing it is a fence behaviour change that deserves its own red-first pin. +- **`flow_json/PLAN_REGISTRY.json` is legacy and no longer read.** @trigger + retired their `plan_file.py` handler on 2026-08-31 after measuring it + themselves: their regex matched **1** of the 366 FPLAN files on disk, so it + had been inert for essentially every plan since the naming convention took a + slug. The handler and its 16 tests are archived and the three `trigger.on()` + registrations are removed, with a comment naming the events as deliberately + unwired. The paragraph below records the state that preceded that. +- **`flow_json/PLAN_REGISTRY.json` was legacy but NOT unread.** No flow code touches it, but `@trigger`'s `apps/handlers/events/plan_file.py` both reads and writes it (`_load_registry`/`_save_registry`), and the file's own contents are the evidence — 1 plan row against `next_number: 402`, last written @@ -393,7 +622,12 @@ aggregation untouched, plus anything auto-closed during the run. in the tree"; that was wrong. Whether @trigger's handler should be pointed at the typed registries is a question for @trigger, not a flow-side fix. - `flow_json/pbplan_registry.json` is an orphaned type registry (see Auto-healing) -- Registry scan fires trigger events that are never handled (by design — foreground close handles everything) +- Registry scan fires trigger events that are never handled — and as of + 2026-08-31 that is a **decision, not an accident**. @trigger removed the three + registrations deliberately when they retired `plan_file.py`, and their + `registry.py` comment says so. An earlier edition of this README called it + "by design" while the handlers were in fact registered; the sentence is true + again, for a different reason. - Dashboard push warns on some closes - `mbank/process.py` at 718 lines (over the 700 limit) - **`CLOSED_PLANS.local.json` carries foreign keys on every branch that has @@ -413,7 +647,7 @@ aggregation untouched, plus anything auto-closed during the run. --- -*Last Updated: 2026-08-25* +*Last Updated: 2026-08-31* --- [← Back to AIPass](../../../README.md) diff --git a/src/aipass/flow/apps/flow.py b/src/aipass/flow/apps/flow.py index effc95a9d..cf89be87e 100755 --- a/src/aipass/flow/apps/flow.py +++ b/src/aipass/flow/apps/flow.py @@ -29,7 +29,12 @@ from pathlib import Path -_PKG_ROOT = Path(__file__).resolve().parents[2] # flow.py → apps/ → flow/ → aipass/ +# NOTE: this file has no module-level path resolution. _PKG_ROOT used to sit +# here as Path(__file__).resolve().parents[2] — a cwd read on Windows at import +# time — and it was assigned once and never read. Removed 2026-08-31 rather than +# routed through handlers/repo_root: the entry point should not reach into +# handlers, and the value was dead. MODULES_DIR below uses Path(__file__).parent +# with no resolve(), which touches no filesystem at all. # Standard library imports import importlib diff --git a/src/aipass/flow/apps/handlers/__init__.py b/src/aipass/flow/apps/handlers/__init__.py index 00ac58961..7c8ead1a9 100644 --- a/src/aipass/flow/apps/handlers/__init__.py +++ b/src/aipass/flow/apps/handlers/__init__.py @@ -1,6 +1,7 @@ """Flow handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "flow" @@ -16,27 +17,57 @@ def _find_real_caller(): - Frozen modules Returns tuple: (file_path, import_line) or (None, None) + + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on the + Windows CI gate 2026-08-31 (@memory's finding, relayed by @devpulse): + inspect.stack() needs a READABLE CWD, and it needs one before any of this + function's own code runs. It builds a FrameInfo per frame, which calls + getsourcefile() -> getmodule() -> os.path.realpath(); ntpath.realpath calls + os.getcwd() unconditionally on its first lines, before it even checks + whether the path is absolute, and that call site in getmodule is not inside + a try. On POSIX the equivalent raise happens earlier, inside getabsfile(), + where inspect catches it — which is why this was invisible on Linux for as + long as it existed. A frame's co_filename is already a string in memory; + reading it touches nothing. """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) + # Path.resolve() reaches the same ntpath.realpath, so this is guarded too — + # __file__ is already absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back + continue - for frame_info in stack: - filename = frame_info.filename + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename # Skip this file - if this_file in str(Path(filename).resolve()): + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - # Skip Python internals - if filename.startswith("<") or "importlib" in filename: - continue - - # Found a real file - try to get the import line - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + # linecache is what inspect used for code_context; called directly it + # reads one named file and returns "" rather than raising when it cannot. + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -64,20 +95,16 @@ def _guard_branch_access(): import os if os.environ.get("AIPASS_DEBUG_GUARD"): - import sys - print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) if caller_file is None: - # Can't determine caller from real files - # Check if we're being run from command line (external) - # by looking at the raw stack for or - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return # Allow command-line Python through - return # Allow if truly can't determine + # No caller outside this file: an interactive session, a -c script, or an + # importlib-only stack. All three are allowed. This used to walk + # inspect.stack() a SECOND time looking for / and then + # return either way — a second copy of the cwd dependency above, in + # service of a branch that could not change the answer. + return # Check if caller is from our branch if f"/{MY_BRANCH}/" in caller_file.replace("\\", "/"): diff --git a/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py b/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py index d3b177b54..0352a0055 100644 --- a/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py +++ b/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py @@ -61,7 +61,9 @@ from aipass.prax.apps.modules.logger import system_logger as logger # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] FLOW_ROOT = _PKG_ROOT / "flow" # Registry location (fallback default) diff --git a/src/aipass/flow/apps/handlers/dashboard/push_central.py b/src/aipass/flow/apps/handlers/dashboard/push_central.py index 4849cd3b5..d80b16d48 100644 --- a/src/aipass/flow/apps/handlers/dashboard/push_central.py +++ b/src/aipass/flow/apps/handlers/dashboard/push_central.py @@ -35,7 +35,9 @@ from aipass.prax.apps.modules.logger import system_logger as logger # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import find_repo_root, module_file + +_PKG_ROOT = module_file(__file__).parents[4] FLOW_ROOT = _PKG_ROOT / "flow" # Handler imports (aggregate_ops is a sibling handler — avoids handler→module layer violation) @@ -51,12 +53,16 @@ def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up to the repo root (the directory holding AIPASS_REGISTRY.json). + + Delegates to ``handlers/repo_root.find_repo_root`` — the one + implementation. This used to be a private copy ending + ``return Path.cwd()``; there were seven such copies in flow and six of + them are called at MODULE level, so on a registry-less checkout every + import guessed its root from the process directory, and with the cwd + deleted the import died outright. See that module's docstring. + """ + return find_repo_root(caller="push_central") _REPO_ROOT = _find_repo_root() diff --git a/src/aipass/flow/apps/handlers/dashboard/update_local.py b/src/aipass/flow/apps/handlers/dashboard/update_local.py index 71d6642a9..7ba456c43 100644 --- a/src/aipass/flow/apps/handlers/dashboard/update_local.py +++ b/src/aipass/flow/apps/handlers/dashboard/update_local.py @@ -75,7 +75,9 @@ from aipass.prax.apps.modules.logger import system_logger as logger # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] FLOW_ROOT = _PKG_ROOT / "flow" # ============================================= diff --git a/src/aipass/flow/apps/handlers/json/json_handler.py b/src/aipass/flow/apps/handlers/json/json_handler.py index 981bba5b3..9e955b00d 100644 --- a/src/aipass/flow/apps/handlers/json/json_handler.py +++ b/src/aipass/flow/apps/handlers/json/json_handler.py @@ -30,12 +30,13 @@ from pathlib import Path from datetime import datetime from typing import Dict, Any, Optional -import inspect from aipass.prax.apps.modules.logger import system_logger as logger # Infrastructure -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] # Constants FLOW_ROOT = _PKG_ROOT / "flow" @@ -48,21 +49,41 @@ def _get_caller_module_name() -> str: Returns: Module name (e.g., "create_plan" from create_plan.py) + + Reads one frame with ``sys._getframe`` rather than building the whole stack + with ``inspect.stack()``. MEASURED on the Windows CI gate 2026-08-31 + (@memory's finding, relayed by @devpulse and reproduced here): building a + ``FrameInfo`` per frame calls ``getsourcefile() -> getmodule() -> + os.path.realpath()``, and ``ntpath.realpath`` reads ``os.getcwd()`` + unconditionally on its first lines. That call site inside ``getmodule`` is + not wrapped in a try. On POSIX the equivalent raise happens earlier, inside + ``getabsfile()``, where inspect catches it — which is why a call on flow's + every-write audit path carried this invisibly for as long as it existed. + + No import probe reaches this line: ``log_operation`` is called at RUNTIME, + and the stack it walks is the CALLER'S. The shape that convicts it is a + ```` frame — a routed subprocess, a hook, anything exec'd — which is + exactly what drone's router produces when it invokes flow. + + A frame's ``f_code.co_filename`` is already a string in memory; reading it + touches no filesystem at all. """ try: - stack = inspect.stack() # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller - if len(stack) > 2: - caller_frame = stack[2] - caller_path = Path(caller_frame.filename) - module_name = caller_path.stem + caller_frame = sys._getframe(2) + caller_path = Path(caller_frame.f_code.co_filename) + module_name = caller_path.stem - # Validate module name - if module_name and not module_name.startswith("_"): - return module_name + # Validate module name + if module_name and not module_name.startswith("_"): + return module_name # Fallback return "unknown" + except ValueError: + # sys._getframe raises when the stack is shallower than the requested + # depth — a direct call with no caller above log_operation. + return "unknown" except Exception as exc: logger.warning("[json_handler] Failed to detect caller module name: %s", exc) return "unknown" diff --git a/src/aipass/flow/apps/handlers/mbank/process.py b/src/aipass/flow/apps/handlers/mbank/process.py index 07a1ef6a5..21c687128 100644 --- a/src/aipass/flow/apps/handlers/mbank/process.py +++ b/src/aipass/flow/apps/handlers/mbank/process.py @@ -22,8 +22,6 @@ # ruff: noqa: E402 from pathlib import Path -_PKG_ROOT = Path(__file__).resolve().parents[4] - # Standard imports import json import os @@ -33,8 +31,11 @@ from typing import Dict, List, Any from aipass.flow.apps.handlers.json import json_handler +from aipass.flow.apps.handlers.repo_root import find_repo_root, module_file from aipass.prax.apps.modules.logger import system_logger as logger +_PKG_ROOT = module_file(__file__).parents[4] + # AI summarization removed — OpenRouter API no longer needed here # from aipass.api.apps.modules.openrouter_client import get_response @@ -76,12 +77,16 @@ def _release_lock(lock_path: Path) -> None: def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up to the repo root (the directory holding AIPASS_REGISTRY.json). + + Delegates to ``handlers/repo_root.find_repo_root`` — the one + implementation. This used to be a private copy ending + ``return Path.cwd()``; there were seven such copies in flow and six of + them are called at MODULE level, so on a registry-less checkout every + import guessed its root from the process directory, and with the cwd + deleted the import died outright. See that module's docstring. + """ + return find_repo_root(caller="mbank_process") _REPO_ROOT = _find_repo_root() diff --git a/src/aipass/flow/apps/handlers/plan/append_closed_plan.py b/src/aipass/flow/apps/handlers/plan/append_closed_plan.py index f4ef2ce4a..29b86c47d 100644 --- a/src/aipass/flow/apps/handlers/plan/append_closed_plan.py +++ b/src/aipass/flow/apps/handlers/plan/append_closed_plan.py @@ -21,7 +21,9 @@ from pathlib import Path # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] # External: Prax logger from aipass.prax.apps.modules.logger import system_logger as logger diff --git a/src/aipass/flow/apps/handlers/plan/auto_cleanup.py b/src/aipass/flow/apps/handlers/plan/auto_cleanup.py index dc637e4fd..f4749caf9 100644 --- a/src/aipass/flow/apps/handlers/plan/auto_cleanup.py +++ b/src/aipass/flow/apps/handlers/plan/auto_cleanup.py @@ -20,7 +20,9 @@ from aipass.flow.apps.handlers.json import json_handler # Infrastructure -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] def auto_close_orphaned_plans(registry: Dict[str, Any]) -> Tuple[Dict[str, Any], int]: diff --git a/src/aipass/flow/apps/handlers/plan/close_helpers.py b/src/aipass/flow/apps/handlers/plan/close_helpers.py index b2b15f877..4ef0353c2 100644 --- a/src/aipass/flow/apps/handlers/plan/close_helpers.py +++ b/src/aipass/flow/apps/handlers/plan/close_helpers.py @@ -39,19 +39,25 @@ # INFRASTRUCTURE # ============================================= -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import find_repo_root, module_file + +_PKG_ROOT = module_file(__file__).parents[4] FLOW_ROOT = _PKG_ROOT / "flow" MODULE_NAME = "close_plan" def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up to the repo root (the directory holding AIPASS_REGISTRY.json). + + Delegates to ``handlers/repo_root.find_repo_root`` — the one + implementation. This used to be a private copy ending + ``return Path.cwd()``; there were seven such copies in flow and six of + them are called at MODULE level, so on a registry-less checkout every + import guessed its root from the process directory, and with the cwd + deleted the import died outright. See that module's docstring. + """ + return find_repo_root(caller="close_helpers") PROCESSED_PLANS_DIR = _find_repo_root() / ".backup" / "processed_plans" diff --git a/src/aipass/flow/apps/handlers/plan/confirmation.py b/src/aipass/flow/apps/handlers/plan/confirmation.py index 8590fedc6..fed8bc737 100644 --- a/src/aipass/flow/apps/handlers/plan/confirmation.py +++ b/src/aipass/flow/apps/handlers/plan/confirmation.py @@ -13,13 +13,14 @@ """ import sys -from pathlib import Path from aipass.prax.apps.modules.logger import system_logger as logger from aipass.flow.apps.handlers.json import json_handler # Infrastructure -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] def confirm_plan_deletion(plan_key: str) -> bool: diff --git a/src/aipass/flow/apps/handlers/plan/create_ops.py b/src/aipass/flow/apps/handlers/plan/create_ops.py index 0927e3778..bf04c34cd 100644 --- a/src/aipass/flow/apps/handlers/plan/create_ops.py +++ b/src/aipass/flow/apps/handlers/plan/create_ops.py @@ -32,7 +32,9 @@ # INFRASTRUCTURE # ============================================= -_PKG_ROOT = Path(__file__).resolve().parents[4] # handlers/plan/ -> handlers/ -> apps/ -> flow/ -> aipass/ +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] # handlers/plan/ -> handlers/ -> apps/ -> flow/ -> aipass/ MODULE_NAME = "create_plan" diff --git a/src/aipass/flow/apps/handlers/plan/get_closed_plans.py b/src/aipass/flow/apps/handlers/plan/get_closed_plans.py index f85d0b4f6..45a7e8fe6 100644 --- a/src/aipass/flow/apps/handlers/plan/get_closed_plans.py +++ b/src/aipass/flow/apps/handlers/plan/get_closed_plans.py @@ -16,13 +16,14 @@ closed_plans = get_closed_plans() """ -from pathlib import Path from typing import List, Tuple, Dict, Any from aipass.prax.apps.modules.logger import system_logger as logger # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] # Internal: Registry handler from aipass.flow.apps.handlers.registry.load_registry import load_registry diff --git a/src/aipass/flow/apps/handlers/plan/get_open_plans.py b/src/aipass/flow/apps/handlers/plan/get_open_plans.py index a90a207e2..820ecc3c5 100644 --- a/src/aipass/flow/apps/handlers/plan/get_open_plans.py +++ b/src/aipass/flow/apps/handlers/plan/get_open_plans.py @@ -16,13 +16,14 @@ open_plans = get_open_plans() """ -from pathlib import Path from typing import List, Tuple, Dict, Any from aipass.prax import logger # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] MODULE_NAME = "get_open_plans" diff --git a/src/aipass/flow/apps/handlers/plan/project_scope.py b/src/aipass/flow/apps/handlers/plan/project_scope.py index 40251ad7a..36f4a10d1 100644 --- a/src/aipass/flow/apps/handlers/plan/project_scope.py +++ b/src/aipass/flow/apps/handlers/plan/project_scope.py @@ -53,6 +53,7 @@ from aipass.prax import logger from aipass.flow.apps.handlers.json import json_handler +from aipass.flow.apps.handlers.repo_root import exactly_named MODULE_NAME = "project_scope" @@ -71,7 +72,14 @@ def _holds_register(directory: Path) -> bool: project and its rows fall out of the real project's scope. """ try: - candidates = sorted(directory.glob(REGISTER_GLOB)) + # exactly_named, not the glob alone: pathlib's glob is case-INSENSITIVE + # on Windows and default macOS, so REGISTER_GLOB also matches flow's own + # lowercase flow_json/*_registry.json plan registries there. Measured on + # the live tree 2026-08-31: 237 files match the folded pattern and 0 of + # them carry a "branches" key, so this was reachable-not-armed — the + # second clause was holding, not the glob. A register-shaped file under a + # lowercase name would make a subdirectory read as its own project. + candidates = exactly_named(sorted(directory.glob(REGISTER_GLOB)), "_REGISTRY.json") except OSError as e: logger.warning(f"[{MODULE_NAME}] Cannot list {directory}: {e}") return False diff --git a/src/aipass/flow/apps/handlers/plan/registry_routing.py b/src/aipass/flow/apps/handlers/plan/registry_routing.py index 7d2dae25c..512910de5 100644 --- a/src/aipass/flow/apps/handlers/plan/registry_routing.py +++ b/src/aipass/flow/apps/handlers/plan/registry_routing.py @@ -36,7 +36,9 @@ MODULE_NAME = "registry_routing" -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] FLOW_ROOT = _PKG_ROOT / "flow" diff --git a/src/aipass/flow/apps/handlers/plan/restore_ops.py b/src/aipass/flow/apps/handlers/plan/restore_ops.py index c6ab7ef73..1c84a254b 100644 --- a/src/aipass/flow/apps/handlers/plan/restore_ops.py +++ b/src/aipass/flow/apps/handlers/plan/restore_ops.py @@ -37,17 +37,23 @@ # INFRASTRUCTURE # ============================================= -_PKG_ROOT = Path(__file__).resolve().parents[4] # handlers/plan/ -> handlers/ -> apps/ -> flow/ -> aipass/ +from aipass.flow.apps.handlers.repo_root import find_repo_root, module_file + +_PKG_ROOT = module_file(__file__).parents[4] # handlers/plan/ -> handlers/ -> apps/ -> flow/ -> aipass/ FLOW_ROOT = _PKG_ROOT / "flow" def _find_repo_root() -> Path: - """Walk up to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up to the repo root (the directory holding AIPASS_REGISTRY.json). + + Delegates to ``handlers/repo_root.find_repo_root`` — the one + implementation. This used to be a private copy ending + ``return Path.cwd()``; there were seven such copies in flow and six of + them are called at MODULE level, so on a registry-less checkout every + import guessed its root from the process directory, and with the cwd + deleted the import died outright. See that module's docstring. + """ + return find_repo_root(caller="restore_ops") PROCESSED_PLANS_DIR = _find_repo_root() / ".backup" / "processed_plans" diff --git a/src/aipass/flow/apps/handlers/registry/heal_registry.py b/src/aipass/flow/apps/handlers/registry/heal_registry.py index 7bf461410..c20fabe41 100644 --- a/src/aipass/flow/apps/handlers/registry/heal_registry.py +++ b/src/aipass/flow/apps/handlers/registry/heal_registry.py @@ -70,6 +70,7 @@ from aipass.flow.apps.handlers.registry.load_registry import load_registry from aipass.flow.apps.handlers.registry.monitor_ops import IGNORE_FOLDERS, PLAN_PATTERN from aipass.flow.apps.handlers.registry.save_registry import save_registry +from aipass.flow.apps.handlers.repo_root import exists_exactly, find_repo_root # ============================================= # CONFIGURATION @@ -452,11 +453,16 @@ def _heal_wrong_prefix_rows( def _find_repo_root() -> Path: - """Walk up to the repo root (the directory holding AIPASS_REGISTRY.json).""" - for parent in Path(__file__).resolve().parents: - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up to the repo root (the directory holding AIPASS_REGISTRY.json). + + Delegates to ``handlers/repo_root.find_repo_root`` — the one + implementation. This used to be a private copy ending + ``return Path.cwd()``; there were seven such copies in flow and six of + them are called at MODULE level, so on a registry-less checkout every + import guessed its root from the process directory, and with the cwd + deleted the import died outright. See that module's docstring. + """ + return find_repo_root(caller="heal_registry") def _is_citizen_seat(path: Path) -> bool: @@ -487,8 +493,13 @@ def _citizen_seat_index() -> Dict[str, List[Path]]: repo_root = _find_repo_root() candidates: set[Path] = set() + # exists_exactly, not is_file(): on a folding filesystem is_file() answers + # True for a file actually named aipass_registry.json. find_repo_root already + # anchors on the exact spelling, so this only bites when it took the + # SOURCE_ROOT fallback — a tree with no registry, where a lowercase lookalike + # would then be read as the fleet anchor. registry_file = repo_root / "AIPASS_REGISTRY.json" - if registry_file.is_file(): + if exists_exactly(registry_file) and registry_file.is_file(): try: import json diff --git a/src/aipass/flow/apps/handlers/registry/load_registry.py b/src/aipass/flow/apps/handlers/registry/load_registry.py index d7fa8d3c2..7c51366bf 100644 --- a/src/aipass/flow/apps/handlers/registry/load_registry.py +++ b/src/aipass/flow/apps/handlers/registry/load_registry.py @@ -23,14 +23,15 @@ """ import json -from pathlib import Path from typing import Dict, Any from aipass.prax.apps.modules.logger import system_logger as logger from aipass.flow.apps.handlers.json import json_handler # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] FLOW_ROOT = _PKG_ROOT / "flow" # ============================================= diff --git a/src/aipass/flow/apps/handlers/registry/save_registry.py b/src/aipass/flow/apps/handlers/registry/save_registry.py index 7756811c2..663dde8cc 100644 --- a/src/aipass/flow/apps/handlers/registry/save_registry.py +++ b/src/aipass/flow/apps/handlers/registry/save_registry.py @@ -35,7 +35,9 @@ from aipass.flow.apps.handlers.json import json_handler # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] FLOW_ROOT = _PKG_ROOT / "flow" # ============================================= diff --git a/src/aipass/flow/apps/handlers/registry/statistics.py b/src/aipass/flow/apps/handlers/registry/statistics.py index ae8a36fd4..e7b8c46e6 100644 --- a/src/aipass/flow/apps/handlers/registry/statistics.py +++ b/src/aipass/flow/apps/handlers/registry/statistics.py @@ -26,14 +26,15 @@ # stats contains: total_plans, open_plans, closed_plans, etc. """ -from pathlib import Path from datetime import datetime, timezone from typing import Dict, Any from aipass.flow.apps.handlers.json import json_handler # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] # ============================================= # HANDLER FUNCTION diff --git a/src/aipass/flow/apps/handlers/repo_root.py b/src/aipass/flow/apps/handlers/repo_root.py new file mode 100644 index 000000000..f357b98d1 --- /dev/null +++ b/src/aipass/flow/apps/handlers/repo_root.py @@ -0,0 +1,245 @@ +# =================== AIPass ==================== +# Name: repo_root.py +# Description: The one guarded answer to "where is my file" and "which repo root is this" +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Location discovery for flow, defined once and guarded. + +WHY THIS FILE EXISTS +-------------------- +Measured on the Windows CI gate 2026-08-31 (@memory's finding, relayed by +@devpulse): ``ntpath.realpath`` calls ``os.getcwd()`` UNCONDITIONALLY — on its +first lines, before it even asks whether the path is absolute, where +``posixpath`` only reads the cwd for a relative one — and ``Path.resolve()`` +routes through ``os.path.realpath``. So on Windows every module-level +``Path(__file__).resolve()`` is an import-time working-directory dependency: a +process whose cwd has been deleted cannot import the module at all. Guarding +inside that module's functions changes nothing; the import died before any of +them existed. + +Flow carried the idiom **29 times**, in every module and nearly every handler, +always spelled ``_PKG_ROOT = Path(__file__).resolve().parents[N]``. Measured +red-first in a subprocess before any cure: 61 of 61 flow modules died on +import, all of them masked at ``handlers/__init__.py`` — the guard crashes +first, so the count only becomes true as cures land (61 dead → 43 after the +guard → 0 after this file). + +WHAT THE REPO-ROOT FALLBACK MUST NEVER BE +----------------------------------------- +``_find_repo_root`` existed as **seven** near-identical private copies in this +tree, each ending ``return Path.cwd()``, and **six of them are called at MODULE +level**. That one line carried two defects. + +THE LOUD ONE: ``AIPASS_REGISTRY.json`` is gitignored and machine-local, so a +clean checkout or a bare CI runner has no marker anywhere above the file and +takes the fallback on EVERY import. With the working directory deleted, +``Path.cwd()`` raises ``FileNotFoundError`` while merely importing — no call +required. + +THE QUIET ONE, which would have outlived the crash: cwd is a GUESS. The +directory a process happened to start in says nothing about where this source +file lives. Four of the seven callers are WRITERS — +``push_central``/``aggregate_central`` build ``.ai_central/PLANS.central.json``, +``close_helpers`` and ``restore_ops`` build the ``.backup/processed_plans`` +archive path — and a writer with a guessed root writes into a tree nobody +chose. A ``try``/``except`` would have fixed the traceback and kept the wrong +answer. + +The source-derived answer is not a guess: on a registry-less checkout it IS the +checkout, which is the true answer there. And the absence is said out loud, +because a fallback nobody can see is how the next one survives. + +A FILENAME IS NOT AN ``exists()`` ON A FOLDING FILESYSTEM +--------------------------------------------------------- +Windows and macOS-default filesystems fold case, so +``(parent / "AIPASS_REGISTRY.json").exists()`` returns True for a file actually +named ``aipass_registry.json``. That is the worse half of the round-3 species +(@drone found the glob form; @seedgo published this literal form as its own +blind spot) because there is no glob in the line to warn a reader. Since +``find_repo_root`` runs at module level in six callers, a folded bait file +would be accepted as THE REPO ROOT and every writer built on it would write +into a tree nobody chose — the quiet defect above, arriving through a different +door. ``exists_exactly`` guards it. + +IMPORTING THIS MODULE MUST NEVER RAISE +-------------------------------------- +Every flow module reaches this file at import time, so anything done here +happens during THEIR import. That is why the audit line on the fallback path is +written defensively: an operations record is worth having, and it is never +worth turning a diagnostic write into the very import crash this module exists +to prevent. The ``json_handler`` import is function-local for the same reason — +``json_handler`` imports THIS module at its own module level, and a module-level +edge back would be a cycle that only appears in whichever import order CI +happened to take. +""" + +import os +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +MODULE_NAME = "repo_root" + +# The marker that defines a repo root: the core registry file. +CORE_REGISTRY = "AIPASS_REGISTRY.json" + + +def module_file(file: str) -> Path: + """A module's own path, absolute, resolved when the filesystem allows. + + ``.resolve()`` is still attempted, because normalising symlinks is why the + call is there and it succeeds on every healthy machine. The fallback is + only reached in the world where the alternative is a dead import, and it is + sound: ``__file__`` has been absolute since Python 3.9, so the return is the + right file either way — just spelled through the symlink rather than past + it. + + Args: + file: A module's ``__file__``. + + Returns: + The module's path, resolved if the filesystem could be asked. + """ + path = Path(file) + try: + return path.resolve() + except OSError as exc: + # debug, not warning: in the world that reaches this line EVERY module + # import takes it, and twenty-nine identical warnings describe one + # condition. The condition itself is reported once, loudly, by whatever + # lane actually fails on it. + logger.debug(f"[{MODULE_NAME}] Cannot resolve {path} ({type(exc).__name__}) — using its absolute spelling") + return path + + +# The repo root this FILE sits in, derived from the layout and nothing else. +# ``src/`` is the marker because it is the one directory the package layout +# guarantees. The last-resort value is the filesystem root: defined, never +# raises, and absurd enough to fail loudly downstream instead of quietly +# resolving against somebody's home directory. +_THIS_FILE = module_file(__file__) +SOURCE_ROOT = next( + (parent.parent for parent in _THIS_FILE.parents if parent.name == "src"), + _THIS_FILE.parents[-1], +) + + +def exists_exactly(path: Path) -> bool: + """True when *path* exists AND is spelled on disk exactly as asked. + + ``Path.exists()`` asks the filesystem, and Windows and macOS-default + filesystems answer about a case-folded name. So a directory holding + ``aipass_registry.json`` reports True for ``AIPASS_REGISTRY.json``, and a + caller that meant the one blessed filename silently gets a different file. + + The only reliable way to learn the real spelling is to LIST the parent — + ``resolve()`` would work on Windows but follows symlinks, so a legitimately + symlinked registry would come back under its target's name and be refused. + The listing is cheap where it matters: it only runs when ``exists()`` + already said yes, which in a walk is at most once. + + An unlistable parent returns True rather than False. This is a READ anchor, + and today's behaviour is ``exists()`` alone; refusing a file that is + demonstrably there because its directory could not be enumerated would be a + new failure invented by the guard. + + Args: + path: The exact filename being asserted. + + Returns: + True when a directory entry with that exact name exists. + """ + candidate = Path(path) + if not candidate.exists(): + return False + try: + with os.scandir(candidate.parent) as entries: + return any(entry.name == candidate.name for entry in entries) + except OSError as exc: + logger.debug( + f"[{MODULE_NAME}] Cannot enumerate {candidate.parent} ({exc}) — trusting exists() for {candidate.name}" + ) + return True + + +def exactly_named(candidates: list[Path], suffix: str) -> list[Path]: + """Keep only the candidates whose filename ends with *suffix* in EXACT case. + + The post-filter for a ``*_REGISTRY.json`` glob. A glob is a pattern the OS + interprets; the rule it spells is about NAMES, and on a folding filesystem + those are not the same set — ``pathlib``'s glob is case-insensitive on + Windows and default macOS, so ``*_REGISTRY.json`` also serves flow's own + eight lowercase ``flow_json/*_registry.json`` plan registries. + + The narrowing only ever removes, and it never reorders — callers sort before + filtering and the order decides which candidate is read first. + + Args: + candidates: Paths returned by a glob. + suffix: The exact-case filename ending required. + + Returns: + The candidates whose ``name`` genuinely ends with *suffix*. + """ + return [path for path in candidates if path.name.endswith(suffix)] + + +def _record_fallback(caller: str, marker: str, current: Path) -> None: + """Log the fallback loudly, and record it without ever raising. + + Called only from the fallback branch, which on six of the callers runs at + module import time. ``log_operation`` writes a file, and a write that fails + in a bare world must not become the import crash this module prevents. + + Args: + caller: Lane that took the fallback. + marker: Filename that was searched for. + current: Directory the walk started from. + """ + logger.warning( + f"[{caller}] No {marker} above {current} — " + f"resolving to the source tree at {SOURCE_ROOT}, never the process directory" + ) + try: + from aipass.flow.apps.handlers.json import json_handler + + json_handler.log_operation( + "repo_root_fallback", + {"caller": caller, "marker": marker, "searched_from": str(current), "resolved": str(SOURCE_ROOT)}, + module_name=MODULE_NAME, + ) + except Exception as exc: # noqa: BLE001 - an audit line must never take an import down + logger.debug(f"[{caller}] repo_root fallback not recorded: {type(exc).__name__}: {exc}") + + +def find_repo_root(start: Path | None = None, *, marker: str = CORE_REGISTRY, caller: str = MODULE_NAME) -> Path: + """Walk up from *start* to the directory holding *marker*. + + Falls back to the root implied by THIS FILE's location — never to the + process working directory. See the module docstring for the two defects + that fallback carried. + + Args: + start: Directory to walk up from. Defaults to this file's directory, + which is what every caller in this tree wants: they all live under + the same ``src/aipass/flow`` package, so it is the same walk + whichever of them asks. + marker: Filename that marks a repo root. + caller: Name used in the log line, so a fallback names the lane that + took it rather than reporting anonymously. + + Returns: + The directory holding *marker*, or ``SOURCE_ROOT`` when no *marker* + exists anywhere above *start*. Never reads the process cwd. + """ + # module_file, not resolve(): six callers reach this line at IMPORT time, + # and on Windows resolve() reads the working directory. + current = Path(start) if start is not None else module_file(__file__).parent + for parent in [current] + list(current.parents): + if exists_exactly(parent / marker): + return parent + _record_fallback(caller, marker, current) + return SOURCE_ROOT diff --git a/src/aipass/flow/apps/handlers/template/get_template.py b/src/aipass/flow/apps/handlers/template/get_template.py index 60c15ce5c..689c7d7cc 100644 --- a/src/aipass/flow/apps/handlers/template/get_template.py +++ b/src/aipass/flow/apps/handlers/template/get_template.py @@ -33,7 +33,9 @@ from aipass.flow.apps.handlers.json import json_handler # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[4] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[4] # ============================================= # CONFIGURATION diff --git a/src/aipass/flow/apps/handlers/template/plan_type_loader.py b/src/aipass/flow/apps/handlers/template/plan_type_loader.py index 738638fb7..9ea653a8c 100644 --- a/src/aipass/flow/apps/handlers/template/plan_type_loader.py +++ b/src/aipass/flow/apps/handlers/template/plan_type_loader.py @@ -32,7 +32,6 @@ from __future__ import annotations -from pathlib import Path from typing import Dict from aipass.flow.apps.handlers.json import json_handler @@ -46,7 +45,9 @@ # Resolve flow root: this file lives at flow/apps/handlers/template/ # so parents[3] walks up to flow/ -FLOW_ROOT = Path(__file__).resolve().parents[3] +from aipass.flow.apps.handlers.repo_root import module_file + +FLOW_ROOT = module_file(__file__).parents[3] PLAN_TYPES_DIR = FLOW_ROOT / "templates" # Prefix map loaded from persistent registry (template_registry.json) diff --git a/src/aipass/flow/apps/handlers/template/registry_ops.py b/src/aipass/flow/apps/handlers/template/registry_ops.py index 9925325a3..d1b6f01c2 100644 --- a/src/aipass/flow/apps/handlers/template/registry_ops.py +++ b/src/aipass/flow/apps/handlers/template/registry_ops.py @@ -45,7 +45,9 @@ MODULE_NAME = "registry_ops" # registry_ops.py -> template/ -> handlers/ -> apps/ -> flow/ -FLOW_ROOT: Path = Path(__file__).resolve().parents[3] +from aipass.flow.apps.handlers.repo_root import module_file + +FLOW_ROOT: Path = module_file(__file__).parents[3] REGISTRY_PATH: Path = FLOW_ROOT / "flow_json" / "template_registry.json" # Default seed data -- ensures existing plan types work on fresh install diff --git a/src/aipass/flow/apps/modules/aggregate_central.py b/src/aipass/flow/apps/modules/aggregate_central.py index 1ea9825ab..84e29aef6 100755 --- a/src/aipass/flow/apps/modules/aggregate_central.py +++ b/src/aipass/flow/apps/modules/aggregate_central.py @@ -44,7 +44,9 @@ from typing import List # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ +from aipass.flow.apps.handlers.repo_root import find_repo_root, module_file + +_PKG_ROOT = module_file(__file__).parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ FLOW_ROOT = _PKG_ROOT / "flow" from aipass.cli.apps.modules import console, error @@ -66,12 +68,16 @@ def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up to the repo root (the directory holding AIPASS_REGISTRY.json). + + Delegates to ``handlers/repo_root.find_repo_root`` — the one + implementation. This used to be a private copy ending + ``return Path.cwd()``; there were seven such copies in flow and six of + them are called at MODULE level, so on a registry-less checkout every + import guessed its root from the process directory, and with the cwd + deleted the import died outright. See that module's docstring. + """ + return find_repo_root(caller="aggregate_central") _REPO_ROOT = _find_repo_root() diff --git a/src/aipass/flow/apps/modules/close_plan.py b/src/aipass/flow/apps/modules/close_plan.py index 35da8f324..08d9f2b78 100644 --- a/src/aipass/flow/apps/modules/close_plan.py +++ b/src/aipass/flow/apps/modules/close_plan.py @@ -30,11 +30,12 @@ if _reconfigure is not None: _reconfigure(encoding="utf-8", errors="replace") -from pathlib import Path from typing import List, Dict, Any # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ FLOW_ROOT = _PKG_ROOT / "flow" # External: Prax logger diff --git a/src/aipass/flow/apps/modules/create_plan.py b/src/aipass/flow/apps/modules/create_plan.py index 579904aa7..f7ed9d283 100755 --- a/src/aipass/flow/apps/modules/create_plan.py +++ b/src/aipass/flow/apps/modules/create_plan.py @@ -39,12 +39,13 @@ if _reconfigure is not None: _reconfigure(encoding="utf-8", errors="replace") -from pathlib import Path from typing import Any, Dict, Tuple, List # ruff: noqa: E402 # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ FLOW_ROOT = _PKG_ROOT / "flow" # External: Prax logger diff --git a/src/aipass/flow/apps/modules/list_plans.py b/src/aipass/flow/apps/modules/list_plans.py index 67b713e73..149bdd023 100755 --- a/src/aipass/flow/apps/modules/list_plans.py +++ b/src/aipass/flow/apps/modules/list_plans.py @@ -39,11 +39,12 @@ if _reconfigure is not None: _reconfigure(encoding="utf-8", errors="replace") -from pathlib import Path from typing import List # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ FLOW_ROOT = _PKG_ROOT / "flow" # External: Prax logger diff --git a/src/aipass/flow/apps/modules/post_close_runner.py b/src/aipass/flow/apps/modules/post_close_runner.py index 120028c90..1cb12ac08 100644 --- a/src/aipass/flow/apps/modules/post_close_runner.py +++ b/src/aipass/flow/apps/modules/post_close_runner.py @@ -30,7 +30,6 @@ if _reconfigure is not None: _reconfigure(encoding="utf-8", errors="replace") -from pathlib import Path from aipass.cli.apps.modules import console, error, success, warning from aipass.flow.apps.handlers.cli.help_flags import wants_help @@ -39,7 +38,9 @@ from aipass.flow.apps.handlers.runner.lock_ops import acquire_lock, release_lock from aipass.prax.apps.modules.logger import system_logger as logger -_PKG_ROOT = Path(__file__).resolve().parents[3] +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[3] FLOW_ROOT = _PKG_ROOT / "flow" MODULE_NAME = "post_close_runner" LOCK_FILE = FLOW_ROOT / ".post_close_runner.lock" diff --git a/src/aipass/flow/apps/modules/registry_monitor.py b/src/aipass/flow/apps/modules/registry_monitor.py index 39882f3b8..5adfd6a96 100644 --- a/src/aipass/flow/apps/modules/registry_monitor.py +++ b/src/aipass/flow/apps/modules/registry_monitor.py @@ -46,7 +46,9 @@ from typing import Dict, Any, List # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ +from aipass.flow.apps.handlers.repo_root import find_repo_root, module_file + +_PKG_ROOT = module_file(__file__).parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ FLOW_ROOT = _PKG_ROOT / "flow" # External: Prax logger @@ -82,12 +84,16 @@ def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up to the repo root (the directory holding AIPASS_REGISTRY.json). + + Delegates to ``handlers/repo_root.find_repo_root`` — the one + implementation. This used to be a private copy ending + ``return Path.cwd()``; there were seven such copies in flow and six of + them are called at MODULE level, so on a registry-less checkout every + import guessed its root from the process directory, and with the cwd + deleted the import died outright. See that module's docstring. + """ + return find_repo_root(caller="registry_monitor") REPO_ROOT = _find_repo_root() diff --git a/src/aipass/flow/apps/modules/restore_plan.py b/src/aipass/flow/apps/modules/restore_plan.py index 299e65a51..d974e2d55 100644 --- a/src/aipass/flow/apps/modules/restore_plan.py +++ b/src/aipass/flow/apps/modules/restore_plan.py @@ -29,11 +29,12 @@ if _reconfigure is not None: _reconfigure(encoding="utf-8", errors="replace") -from pathlib import Path from typing import List, Dict, Any # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ FLOW_ROOT = _PKG_ROOT / "flow" # External: Prax logger diff --git a/src/aipass/flow/apps/modules/template_manager.py b/src/aipass/flow/apps/modules/template_manager.py index 100287a7f..eeb81c482 100644 --- a/src/aipass/flow/apps/modules/template_manager.py +++ b/src/aipass/flow/apps/modules/template_manager.py @@ -32,12 +32,13 @@ if _reconfigure is not None: _reconfigure(encoding="utf-8", errors="replace") -from pathlib import Path from typing import List # ruff: noqa: E402 # INFRASTRUCTURE IMPORT PATTERN -_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ +from aipass.flow.apps.handlers.repo_root import module_file + +_PKG_ROOT = module_file(__file__).parents[3] # file.py -> modules/ -> apps/ -> flow/ -> aipass/ FLOW_ROOT = _PKG_ROOT / "flow" # External: Prax logger diff --git a/src/aipass/flow/pytest.ini b/src/aipass/flow/pytest.ini index 027f04c6b..512bf0c5e 100644 --- a/src/aipass/flow/pytest.ini +++ b/src/aipass/flow/pytest.ini @@ -19,3 +19,6 @@ markers = unit: Unit tests integration: Integration tests slow: Tests that take significant time +# real_logger is registered in tests/conftest.py, NOT here: this file is not read +# when CI runs the composed suite with -c pyproject.toml, and a marker declared +# in two places drifts. conftest.py is loaded in both universes. diff --git a/src/aipass/flow/tests/conftest.py b/src/aipass/flow/tests/conftest.py index 73ca6a70f..923194c88 100644 --- a/src/aipass/flow/tests/conftest.py +++ b/src/aipass/flow/tests/conftest.py @@ -13,7 +13,8 @@ import shutil from pathlib import Path from typing import Generator -from unittest.mock import patch +import sys +from unittest.mock import MagicMock, patch # Pre-import modules so patch() path resolution works. # Without these imports, the intermediate packages lack the sub-module @@ -22,12 +23,104 @@ import aipass.flow.apps.handlers.json.json_handler # noqa: F401 import aipass.cli.apps.modules # noqa: F401 +# Pre-import every module that calls find_repo_root() at MODULE level, for a +# different reason: to move an IMPORT-TIME diagnostic out of every test window. +# +# find_repo_root logs repo_root_fallback through json_handler.log_operation when +# the marker walk finds nothing. AIPASS_REGISTRY.json is gitignored and +# machine-local, so on a dev box the fallback never runs and on a bare CI +# checkout it runs on EVERY import. These six modules take that walk while +# LOADING, so on CI the diagnostic lands in whichever test window happens to +# trigger the first import — and mock_json_handler (autouse) counts it. +# +# That is what reddened test_push_central on all four Python versions of commit +# 28ee90d5 (round 5, @devpulse). The count it broke was not wrong; it was +# measuring the host. A per-test fix would have left the other sites one +# xdist worker-split away: measured in a bare world, running each +# count-asserting test in FULL isolation, TWO of the ten fail — CI had only +# found one. Importing here settles the walk before any test window exists, on +# every machine, and the counts go back to meaning what they say. +# +# TestThePreImportListIsComplete (tests/test_conftest_fixtures.py) fails if a +# new module-level caller appears and is not added here. +import aipass.flow.apps.handlers.dashboard.push_central # noqa: F401 +import aipass.flow.apps.handlers.mbank.process # noqa: F401 +import aipass.flow.apps.handlers.plan.close_helpers # noqa: F401 +import aipass.flow.apps.handlers.plan.restore_ops # noqa: F401 +import aipass.flow.apps.modules.aggregate_central # noqa: F401 +import aipass.flow.apps.modules.registry_monitor # noqa: F401 + + +def pytest_configure(config): + """Register flow's markers where BOTH test universes can see them. + + The commit gate runs this suite with rootdir pinned to the branch by + ``pytest.ini``; CI composes every conftest in one process from the repo root + under ``-c pyproject.toml``, where ``pytest.ini`` is never read. A marker + registered only in ``pytest.ini`` is therefore unknown in CI — two + PytestUnknownMarkWarning per run, and under a stricter config a hard error. + ``conftest.py`` is loaded in both universes, so this is the one place a + branch-local marker can be declared once and mean the same thing twice. + + Same species as FPLAN-0461: green per-branch says nothing about the composed + world. Caught here by running both rootdirs, which is why that is the rule. + + Args: + config: The pytest config being built. + """ + config.addinivalue_line( + "markers", + "real_logger: opt out of the autouse mock_logger fixture — for tests whose " + "SUBJECT is the log line (caplog reads the real logging module)", + ) + @pytest.fixture(autouse=True) -def mock_logger(): - """Mock prax logger to prevent real log writes.""" - with patch("aipass.prax.apps.modules.logger.system_logger") as mock: - yield mock +def mock_logger(request, monkeypatch): + """Mock the prax logger every flow module actually calls. + + FIXED 2026-08-31. This used to patch + ``aipass.prax.apps.modules.logger.system_logger`` — the SOURCE attribute — + while every flow module does ``from ... import system_logger as logger`` at + import time and therefore holds its OWN binding. Patching upstream of a + binding that was already taken reaches nothing, and it was measured + reaching nothing: with the old patch active, a consumer's ``logger`` was + still the real object. @seedgo published the technique alongside the + standard after @prax ruled (``drone @seedgo standard imports``); the sibling + fixture ten lines down, ``mock_json_handler``, had it right all along + because it patches an attribute the consumer resolves at CALL time. + + No test lost or gained an assertion: three tests take this fixture as a + parameter and none of them assert on it, so there was no assertion surface + to break — which is also why the broken version never showed up as a + failure. + + Nothing leaked in the meantime. Containment comes from + ``AIPASS_TEST_LOG_DIR``, set at the top of this file ahead of every import; + a broken fixture sitting behind a working mechanism is a different thing + from a leak, and reporting it as one would have sent the wrong fix. + + OPT OUT with ``@pytest.mark.real_logger`` when the test's SUBJECT is the log + line — ``caplog`` reads the real logging module, and a mock that swallows + the call makes such a test vacuous. Found the honest way: turning this + fixture on reddened exactly one test, and that test was right. The first + measurement ("three tests take this fixture and none assert on it") counted + the wrong population; caplog users take no fixture parameter at all. + """ + if request.node.get_closest_marker("real_logger"): + yield None + return + + mock = MagicMock() + for name, module in list(sys.modules.items()): + if not name.startswith("aipass.flow.apps"): + continue + for attribute in ("logger", "system_logger"): + if getattr(module, attribute, None) is not None: + monkeypatch.setattr(module, attribute, mock, raising=False) + # The source too, so a module imported LATER in the test binds the mock. + monkeypatch.setattr("aipass.prax.apps.modules.logger.system_logger", mock) + yield mock @pytest.fixture(autouse=True) diff --git a/src/aipass/flow/tests/test_aggregate_ops.py b/src/aipass/flow/tests/test_aggregate_ops.py index 318339218..40172ef46 100644 --- a/src/aipass/flow/tests/test_aggregate_ops.py +++ b/src/aipass/flow/tests/test_aggregate_ops.py @@ -2,6 +2,8 @@ import json import logging + +import pytest from pathlib import Path from unittest.mock import patch @@ -645,6 +647,7 @@ def test_run_twice_is_stable(self, tmp_path): assert first["total_closed"] == second["total_closed"] == 104 + @pytest.mark.real_logger def test_missing_upstream_total_warns_instead_of_publishing_silently(self, tmp_path, caplog): """No upstream count = we cannot know the real total. Say so, don't invent one.""" aggregate_central_impl = _import("aggregate_central_impl") diff --git a/src/aipass/flow/tests/test_conftest_fixtures.py b/src/aipass/flow/tests/test_conftest_fixtures.py new file mode 100644 index 000000000..9044c8c1e --- /dev/null +++ b/src/aipass/flow/tests/test_conftest_fixtures.py @@ -0,0 +1,172 @@ +# =================== AIPass ==================== +# Name: test_conftest_fixtures.py +# Description: The autouse fixtures must actually reach what they claim to mock +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""A mock that reaches nothing passes every test that does not assert on it. + +``mock_logger`` patched ``aipass.prax.apps.modules.logger.system_logger`` — the +SOURCE attribute — while every flow module does +``from ... import system_logger as logger`` at import time and holds its own +binding. Patching upstream of a binding already taken reaches nothing, and it +reached nothing for as long as the fixture existed. Nothing failed, because a +mock nobody asserts on is indistinguishable from a mock that works. + +@seedgo published the technique next to the standard after @prax ruled +(``drone @seedgo standard imports``). @spawn's note is the one worth keeping: +the correct technique already existed in their tree at one call site, written +before any of this came up — it just was not in the shared fixture. The right +answer existed in the fleet and nobody had written it down. + +These tests are the writing-down. They assert the fixture's REACH, not its +existence, so the next upstream/downstream mix-up is a red test rather than a +silent hole. +""" + +import sys +from pathlib import Path + +import pytest + +import aipass.flow.apps.handlers.registry.load_registry as load_registry_module + + +class TestMockLoggerReachesTheConsumersBinding: + """The fixture must replace what flow modules actually call.""" + + def test_a_consumer_module_sees_the_mock(self, mock_logger): + """The binding a handler resolves at call time IS the mock.""" + assert load_registry_module.logger is mock_logger, ( + "the autouse mock_logger did not reach a consumer's own binding — " + "it is patching upstream of an import that already happened" + ) + + def test_calling_the_consumers_logger_records_on_the_mock(self, mock_logger): + """Reach is not enough: the call has to land.""" + load_registry_module.logger.warning("probe") + + mock_logger.warning.assert_called_once_with("probe") + + def test_every_imported_flow_module_with_a_logger_is_covered(self, mock_logger): + """No module keeps a live logger while the fixture claims containment. + + Walks ``sys.modules`` rather than a written list, for the same reason + the dead-cwd pin does: the species is a fix landing on some of N + identical paths, and a hand-written list is one more place for N to be + undercounted. + """ + missed = [ + name + for name, module in sys.modules.items() + if name.startswith("aipass.flow.apps") + and getattr(module, "logger", None) is not None + and module.logger is not mock_logger + ] + + assert missed == [], f"modules still holding a live logger: {missed}" + + def test_the_source_attribute_is_patched_too(self, mock_logger): + """A module imported LATER in a test must bind the mock, not the real one.""" + import aipass.prax.apps.modules.logger as prax_logger + + assert prax_logger.system_logger is mock_logger + + +class TestTheOptOutIsRealAndNarrow: + """``real_logger`` exists because caplog reads the real logging module.""" + + @pytest.mark.real_logger + def test_marked_tests_keep_the_real_logger(self): + """The marker must genuinely restore the real object, not a second mock.""" + from unittest.mock import MagicMock + + assert not isinstance(load_registry_module.logger, MagicMock), ( + "the real_logger marker did not restore the real logger — a caplog assertion under it would be vacuous" + ) + + def test_unmarked_tests_still_get_the_mock(self, mock_logger): + """Negative control: the opt-out must not leak to its neighbours.""" + assert load_registry_module.logger is mock_logger + + +class TestThePreImportListIsComplete: + """A new module-level ``find_repo_root`` caller must not re-arm the landmine. + + ``conftest.py`` pre-imports the six modules that walk for the repo root while + LOADING, so the fallback's import-time ``log_operation`` cannot land inside a + test window on a bare checkout. That fix is only as good as the list, and a + list in a test file is exactly where an undercount hides — so the list is + MEASURED off the tree by parse and compared against what ``conftest.py`` + actually imports, also by parse. Neither side is hand-copied here. + + Round 5's defect (@devpulse): a count assertion that was green on every dev + machine and red on all four Python versions of CI, because the marker it + depended on is gitignored. Measured in a bare world with every + count-asserting test run in FULL isolation, TWO of the ten failed — CI had + named one. This pin is why the eighth one cannot come back quietly. + """ + + @staticmethod + def _module_level_repo_root_callers() -> set[str]: + """Modules whose repo-root walk is evaluated when they load.""" + import ast + + import aipass.flow.apps as flow_apps + + root = Path(flow_apps.__file__).parent + callers = set() + for source in sorted(root.rglob("*.py")): + if "__pycache__" in source.parts or ".archive" in source.parts: + continue + tree = ast.parse(source.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + for sub in ast.walk(node): + if ( + isinstance(sub, ast.Call) + and isinstance(sub.func, ast.Name) + and sub.func.id in ("find_repo_root", "_find_repo_root") + ): + rel = source.relative_to(root).with_suffix("") + parts = [part for part in rel.parts if part != "__init__"] + callers.add(".".join(["aipass.flow.apps", *parts])) + return callers + + @staticmethod + def _conftest_preimports() -> set[str]: + """Dotted names ``conftest.py`` imports at module level, by parse.""" + import ast + + conftest = Path(__file__).parent / "conftest.py" + tree = ast.parse(conftest.read_text(encoding="utf-8")) + return {alias.name for node in tree.body if isinstance(node, ast.Import) for alias in node.names} + + def test_every_module_level_caller_is_pre_imported(self): + missing = self._module_level_repo_root_callers() - self._conftest_preimports() + + assert missing == set(), ( + "a module walks for the repo root at IMPORT time and conftest.py does not " + "pre-import it — on a bare checkout its fallback log lands in whichever test " + f"window triggers the first import, and any count assertion there breaks: {sorted(missing)}" + ) + + def test_the_caller_detector_actually_finds_them(self): + """Control: an empty detector would make the test above vacuously green.""" + callers = self._module_level_repo_root_callers() + + assert len(callers) >= 6, f"the parse found only {len(callers)} module-level callers — it is not measuring" + assert "aipass.flow.apps.handlers.dashboard.push_central" in callers, ( + "the module CI reddened is not in the measured set" + ) + + def test_the_conftest_parse_actually_reads_the_imports(self): + """Control for the other side of the comparison.""" + preimports = self._conftest_preimports() + + assert "aipass.flow.apps.handlers.json.json_handler" in preimports, ( + "the conftest parse is not seeing imports that are demonstrably there" + ) diff --git a/src/aipass/flow/tests/test_handlers_init.py b/src/aipass/flow/tests/test_handlers_init.py index 3223702d8..400b6ad3c 100644 --- a/src/aipass/flow/tests/test_handlers_init.py +++ b/src/aipass/flow/tests/test_handlers_init.py @@ -8,8 +8,11 @@ """Tests for handlers/__init__.py — branch access guard.""" +import subprocess +import sys +import tempfile from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -77,86 +80,91 @@ def test_forward_slash_path(self): # =================================================================== -def _make_frame_info(filename: str, code_context: list[str] | None = None): - """Create a lightweight stand-in for inspect.FrameInfo.""" - fi = MagicMock() - fi.filename = filename - fi.code_context = code_context - return fi - - class TestFindRealCaller: - """Walk the stack and return the first real (non-internal) file.""" - - def test_returns_real_file(self): - """When the stack contains a real file, return its resolved path and import line.""" - real_file = "/home/user/Projects/AIPass/src/aipass/flow/apps/modules/foo.py" - # Build a stack: __init__.py (skipped), importlib (skipped), then the real caller - init_path = str(Path(__file__).resolve().parent.parent / "apps" / "handlers" / "__init__.py") - frames = [ - _make_frame_info(init_path), - _make_frame_info(""), - _make_frame_info(real_file, ["from aipass.flow.apps.handlers import something\n"]), - ] - - with patch("aipass.flow.apps.handlers.inspect.stack", return_value=frames): - filepath, import_line = _find_real_caller() - - assert filepath is not None - assert filepath == str(Path(real_file).resolve()) - assert import_line is not None - assert "from aipass.flow.apps.handlers" in import_line + """Walk the stack and return the first real (non-internal) file. + + REWRITTEN 2026-08-31. These tests used to patch + ``aipass.flow.apps.handlers.inspect.stack`` and feed it MagicMock + FrameInfos. That mock is why the defect below lived here undisturbed: + ``inspect.stack()`` builds a FrameInfo per frame, which reaches + ``getmodule()``'s unguarded ``os.path.realpath`` — a cwd read on Windows, + before any of the guard's own code runs — and a stack that never executes + cannot demonstrate that. The walk is ``sys._getframe`` now, so these drive + REAL frames: ``compile(..., filename)`` gives a frame whatever + ``co_filename`` the case needs, which is the same lever the mock provided + and costs nothing in fidelity. + """ + + @staticmethod + def _call_from(filename: str, source: str = "RESULT = _frc()"): + """Run ``_find_real_caller()`` inside a frame named *filename*.""" + namespace = {"_frc": _find_real_caller, "RESULT": None} + exec(compile(source, filename, "exec"), namespace) + return namespace["RESULT"] + + def test_returns_real_file(self, tmp_path): + """A real file on the stack comes back resolved, with its source line.""" + caller = tmp_path / "foo.py" + caller.write_text("PADDING = 1\nRESULT = _frc()\n", encoding="utf-8") + + filepath, import_line = self._call_from(str(caller), caller.read_text(encoding="utf-8")) + + assert filepath == str(caller.resolve()) + # linecache reads the line the calling frame is ON. In production that + # line is the import statement the guard fired for. + assert import_line == "RESULT = _frc()" def test_skips_importlib_internals(self): """Frames with 'importlib' in the filename are skipped.""" - frames = [ - _make_frame_info("/usr/lib/python3/importlib/__init__.py"), - _make_frame_info("/usr/lib/python3/importlib/_bootstrap.py"), - _make_frame_info("/home/user/real_script.py", ["import handlers\n"]), - ] - - with patch("aipass.flow.apps.handlers.inspect.stack", return_value=frames): - filepath, _ = _find_real_caller() + filepath, _ = self._call_from("/usr/lib/python3/importlib/_bootstrap.py") assert filepath is not None - assert "real_script" in filepath + assert "importlib" not in filepath + # The next real frame up is this test file itself. + assert filepath.endswith("test_handlers_init.py") def test_skips_angle_bracket_filenames(self): - """Frames with filenames starting with '<' are skipped.""" - frames = [ - _make_frame_info(""), - _make_frame_info(""), - _make_frame_info("/home/user/caller.py", ["import x\n"]), - ] + """Frames whose filename starts with '<' are skipped. - with patch("aipass.flow.apps.handlers.inspect.stack", return_value=frames): - filepath, _ = _find_real_caller() + Skipped BEFORE the filesystem is touched: ``resolve()`` on ```` + needs a cwd, and a process whose cwd was deleted dies on that line. + """ + filepath, _ = self._call_from("") assert filepath is not None - assert "caller.py" in filepath + assert not filepath.startswith("<") + assert filepath.endswith("test_handlers_init.py") def test_returns_none_when_no_real_frames(self): - """When every frame is an internal or angle-bracket frame, return (None, None).""" - frames = [ - _make_frame_info(""), - _make_frame_info(""), - _make_frame_info(""), - ] - - with patch("aipass.flow.apps.handlers.inspect.stack", return_value=frames): - filepath, import_line = _find_real_caller() - - assert filepath is None - assert import_line is None + """Every frame internal or angle-bracket → (None, None). + + Run in a subprocess because it is the only way to own the WHOLE stack: + inside pytest the frames above this one are real files, so the walk + would rightly find one. A ``python -c`` process has a single + ```` frame and nothing else — which is also exactly the shape + production hits when drone routes a command. + """ + script = ( + "from aipass.flow.apps.handlers import _find_real_caller\n" + "ns = {'_frc': _find_real_caller, 'RESULT': None}\n" + "exec(compile('RESULT = _frc()', '', 'exec'), ns)\n" + "print('RESULT:', ns['RESULT'])\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=120) + + assert result.returncode == 0, result.stdout + result.stderr + assert "RESULT: (None, None)" in result.stdout, result.stdout def test_none_code_context(self): - """When code_context is None, import_line is returned as None.""" - frames = [ - _make_frame_info("/home/user/script.py", None), - ] + """A frame naming a file that is not on disk yields import_line None. + + linecache returns "" rather than raising for a file it cannot read — + which is the whole reason it replaced inspect's code_context here. + """ + missing = str(Path(tempfile.gettempdir()) / "flow_no_such_file_9f3c.py") + assert not Path(missing).exists() - with patch("aipass.flow.apps.handlers.inspect.stack", return_value=frames): - filepath, import_line = _find_real_caller() + filepath, import_line = self._call_from(missing) assert filepath is not None assert import_line is None @@ -215,59 +223,58 @@ def test_error_message_contains_import_line(self): with pytest.raises(ImportError, match="json_handler"): _guard_branch_access() - def test_allows_when_caller_none_with_string_in_stack(self): - """When caller is None and is in the stack, allow through.""" - string_frame = MagicMock() - string_frame.filename = "" - - with ( - patch( - "aipass.flow.apps.handlers._find_real_caller", - return_value=(None, None), - ), - patch( - "aipass.flow.apps.handlers.inspect.stack", - return_value=[string_frame], - ), - ): - # Should not raise - _guard_branch_access() - - def test_allows_when_caller_none_with_stdin_in_stack(self): - """When caller is None and is in the stack, allow through.""" - stdin_frame = MagicMock() - stdin_frame.filename = "" - - with ( - patch( + def test_allows_when_caller_none_whatever_is_on_the_stack(self): + """caller is None → allowed, and nothing else is consulted. + + REPLACES three tests (```` on the stack, ```` on the + stack, neither) that each pinned one leg of a SECOND ``inspect.stack()`` + walk inside this branch. That walk returned "allow" on every path it + could take, so it was a second copy of the cwd dependency in service of + a branch that could not change the answer — deleted 2026-08-31. Three + tests asserting the same allow through three routes read as coverage and + were really one contract, so it is stated once here. + + The three worlds are still exercised: each drives the REAL walk to None + by owning the whole stack in a subprocess, which the mocked version + never did. + + NO IMPORT-SHAPED pin can reach the DELETION — ``apps/__init__.py`` + always supplies a real-file frame, so no import enters this branch at all + (@trigger restored the walk in their tree and 1058 tests stayed green). + An earlier version of this docstring said behaviour could not pin it at + all; @spawn measured the correction (relayed by @devpulse 2026-08-31). + Calling the guard DIRECTLY from a ``python -c`` child does reach it, and + ``tests/test_import_dead_cwd.py`` carries both instruments now: the AST + ban and that behavioural sibling. A regrown walk kills both. + """ + for world in ("", "", "no special frames"): + with patch( "aipass.flow.apps.handlers._find_real_caller", return_value=(None, None), - ), - patch( - "aipass.flow.apps.handlers.inspect.stack", - return_value=[stdin_frame], - ), - ): - # Should not raise - _guard_branch_access() - - def test_allows_when_caller_none_no_special_frames(self): - """When caller is None and no special frames exist, allow through (can't determine).""" - normal_frame = MagicMock() - normal_frame.filename = "/usr/lib/python3/importlib/_bootstrap.py" + ): + # Should not raise, in any of the three. + _guard_branch_access() - with ( - patch( - "aipass.flow.apps.handlers._find_real_caller", - return_value=(None, None), - ), - patch( - "aipass.flow.apps.handlers.inspect.stack", - return_value=[normal_frame], - ), - ): - # Should not raise — falls through to the final return - _guard_branch_access() + def test_the_caller_none_branch_is_reached_by_a_real_stack(self): + """Control for the test above: None is reachable without patching. + + Patching ``_find_real_caller`` to return None proves the guard's + RESPONSE, not that the world exists. A ``python -c`` process is the + world — one ```` frame, nothing above it — and it must import + flow's handlers without raising. + """ + script = ( + "import aipass.flow.apps.handlers as h\n" + "ns = {'_frc': h._find_real_caller, 'RESULT': None}\n" + "exec(compile('RESULT = _frc()', '', 'exec'), ns)\n" + "assert ns['RESULT'] == (None, None), ns['RESULT']\n" + "exec(compile('h._guard_branch_access()', '', 'exec'), {'h': h})\n" + "print('ALLOWED')\n" + ) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=120) + + assert result.returncode == 0, result.stdout + result.stderr + assert "ALLOWED" in result.stdout, result.stdout def test_blocked_import_says_unknown_when_no_import_line(self): """When import_line is None, the error message says 'unknown'.""" diff --git a/src/aipass/flow/tests/test_import_dead_cwd.py b/src/aipass/flow/tests/test_import_dead_cwd.py new file mode 100644 index 000000000..a9bf44e1d --- /dev/null +++ b/src/aipass/flow/tests/test_import_dead_cwd.py @@ -0,0 +1,1970 @@ +# =================== AIPass ==================== +# Name: test_import_dead_cwd.py +# Description: Every flow module imports, and keeps logging, without a readable working directory +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Flow must import, and must keep its audit line alive, with no cwd. + +THE MECHANISM, measured on the Windows CI gate 2026-08-31 (@memory's finding, +relayed by @devpulse). ``ntpath.realpath`` calls ``os.getcwd()`` +UNCONDITIONALLY — on its first lines, before it even asks whether the path is +absolute — where ``posixpath`` only reads the cwd for a relative one. And +``Path.resolve()`` routes through ``os.path.realpath``. So on Windows every +module-level ``Path(__file__).resolve()`` is an import-time working-directory +dependency, and a process whose cwd is gone cannot import the module at all. +Guarding INSIDE that module's functions changes nothing: the import died before +any of them existed. + +``inspect.stack()`` carries the same defect one layer down and needs a +DIFFERENT world to convict it. It builds a ``FrameInfo`` per frame, and for a +frame whose filename is a PSEUDO-file it reaches ``getmodule()``, whose +``os.path.realpath(f)`` sits outside every ``try`` in that function. On POSIX +the equivalent raise happens EARLIER, inside ``getabsfile()``, where ``inspect`` +catches it — which is exactly why two calls on flow's every-import and +every-write paths survived years of Linux CI carrying this. + +TWO WORLDS, and @seedgo's asymmetry is why both are here rather than one: + +* **World A** emulates ntpath — ``os.path.realpath`` is wrapped to read + ``os.getcwd()`` first, then ``os.getcwd`` is denied. This convicts a raw + ``resolve()``. It does NOT convict ``inspect.stack()`` on Linux, because + ``getabsfile`` raises inside inspect's own catch before ``getmodule`` is + reached. +* **World B** denies ``os.path.realpath`` outright while ``abspath`` keeps + working. This is what reaches ``getmodule``'s unguarded call and convicts + ``inspect.stack()``. + +THIRD INGREDIENT for world B (@hooks): the frame must be ```` — an +interpreter ``-c`` or ``compile()`` frame — and NEVER ````. A +heredoc-fed child puts ```` in ``linecache.cache``, ``getsourcefile`` +early-returns, and the probe reports green while the same world kills imports +for real. Every assertion below is preceded by a control that states whether its +world is armed, so a probe that quietly stopped biting cannot pass itself off as +a cure. + +WHAT FLOW CARRIED, measured not estimated. Before this build, **61 of 61** flow +modules died on import in BOTH worlds — every one of them inside +``handlers/__init__.py``, at the ``inspect.stack()`` on line 20 and the +``Path(__file__).resolve()`` on line 21. Those two lines MASKED everything under +them, which is why the count only became true as cures landed: curing the guard +took it to 43/61 and revealed ``json/json_handler.py:38`` (masking 31 modules on +its own) plus twelve more; routing all **29** module-level +``Path(__file__).resolve()`` sites and all **7** private ``_find_repo_root`` +copies through ``handlers/repo_root.py`` took it to **0/62**. + +AND ONE LIVE SITE NO IMPORT PROBE REACHES. ``log_operation`` is the audit line +flow writes on essentially every registry operation, and its +``_get_caller_module_name`` called ``inspect.stack()``. The stack it walks is +the CALLER'S, so the shape that convicts it is a ```` frame — which is +precisely what @drone's router produces when it invokes flow. +""" + +import ast +import subprocess +import sys +from pathlib import Path + +import pytest + +# Other branches' import-time code is held CONSTANT: preloaded in the healthy +# world, before any denial. Their cure is their own build; this file measures +# flow's sites and must not go red in someone else's name. These are flow's only +# cross-branch module-level imports, and they are TEMPORARY — delete a line once +# that branch's own dead-cwd pin is green. +# +# @trigger is DELIBERATELY not preloaded. close_plan.py imports their core at +# module level behind `except ImportError`, and the dispatch asked whether that +# should widen to (ImportError, OSError). MEASURED here rather than assumed: +# `from aipass.trigger.apps.modules.core import trigger` succeeds in BOTH worlds +# — their own cure has landed — so widening would add a catch for a condition +# that does not occur. Leaving trigger out of the preload is what keeps that +# measurement live: if their import ever starts raising OSError, close_plan dies +# for real and the fan below reds with close_plan.py named, which is the honest +# report. Widening the except would have hidden exactly that. +_PRELOAD = """ +import aipass.prax # noqa: F401 +import aipass.prax.apps.modules.logger # noqa: F401 +import aipass.cli.apps.modules # noqa: F401 +import aipass.api # noqa: F401 +""" + +# THE CAPTURED ACCESSOR, and why every world below ends with it. +# +# Python 3.10's pathlib delegates Path.resolve through _NormalAccessor, and that +# class took its copy of os.path.realpath at CLASS-DEFINITION time — when +# pathlib was first imported (CPython 3.10 Lib/pathlib.py:358, +# `realpath = staticmethod(os.path.realpath)`, read by Path.resolve at :1077 as +# `self._accessor.realpath(self, strict=strict)`). So a world that imports +# pathlib and THEN rebinds os.path.realpath is rebinding a name nothing will +# read again: the denial lands on the module attribute and the accessor never +# sees it. 3.11 removed the accessor and calls os.path.realpath at use, which is +# why exactly one interpreter reddened. +# +# CI found it on commit 8550ed10, Python 3.10 only: world A's arming probe +# printed RESOLVE_DIES: NO and refused, which is the probe doing its job — every +# pin underneath it would otherwise have been vacuously green on that leg. +# (@memory read the CPython source, @seedgo reproduced by construction; relayed +# by @devpulse 2026-08-31.) +# +# Patching the accessor when one EXISTS makes the world order-independent and +# identical on every interpreter — no version table, no skipif, no row that only +# one leg can falsify. Four details, each of which silently un-arms the world: +# - `staticmethod`, or the accessor passes itself as the first argument and +# the patch resolves the accessor OBJECT rather than the path; +# - `*a, **k`, because Path.resolve passes `strict` and a bound call adds one; +# - exercised through an INSTANCE, since a class-level read never binds; +# - probed with an ABSOLUTE path, because posixpath.realpath reads the cwd for +# a relative one whatever else is patched — the world would then convict for +# the path's SHAPE and look like success. +_ACCESSOR_CURE = """ +import pathlib as _pathlib_accessor + +if hasattr(_pathlib_accessor, "_NormalAccessor"): + _pathlib_accessor._NormalAccessor.realpath = staticmethod(_denied_realpath) +""" + +_WORLD_A = ( + """ +import os + +_real_realpath = os.path.realpath + + +def _denied_realpath(path, *a, **k): + os.getcwd() # ntpath.realpath reads cwd before checking absoluteness + return _real_realpath(path, *a, **k) + + +os.path.realpath = _denied_realpath + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd +""" + + _ACCESSOR_CURE +) + +_WORLD_B = ( + """ +import os + + +def _denied_realpath(path, *a, **k): + raise FileNotFoundError(2, "realpath denied", "") + + +os.path.realpath = _denied_realpath +""" + + _ACCESSOR_CURE +) + +# Does THIS interpreter's resolve() reach the denied call for an ABSOLUTE path? +# +# CORRECTED 2026-08-31. This comment used to read "3.10 resolves absolute paths +# without touching the cwd, so the denial cannot fire there" — inherited from the +# round-4 reference and WRONG, the same first diagnosis @devpulse retracted. +# 3.10 DOES route resolve() through os.path.realpath; it just reads a copy +# captured at pathlib's import (see _ACCESSOR_CURE above). The distinction +# matters to whoever reads this next: the false version sends you to a version +# table or a skipif, and the true one is cured by four lines that work +# everywhere. With the accessor patched, this probe now answers YES on every +# supported interpreter, and a NO is a broken instrument rather than a fact +# about the host. +_RESOLVE_CONTROL = """ +import pathlib + +try: + pathlib.Path(pathlib.__file__).resolve() + print("RESOLVE_DIES: NO") +except OSError: + print("RESOLVE_DIES: YES") +""" + +# The control for world B, and it MUST ride a frame — see the module +# docstring. compile(..., "") gives the frame a pseudo-filename with +# nothing in linecache, which is the shape getmodule's unguarded realpath is +# reached for. +_STACK_CONTROL = """ +import inspect + + +def _probe(): + try: + inspect.stack() + return "NO" + except OSError: + return "YES" + + +_ns = {"_probe": _probe, "out": None} +exec(compile("out = _probe()", "", "exec"), _ns) +print("STACK_DIES: " + _ns["out"]) +""" + + +def _flow_modules() -> list[str]: + """Every importable module under ``aipass.flow.apps``, by walking the tree. + + Named from the filesystem rather than from a hand-written list: the whole + species this file is about is a fix landing on some of N identical paths, + and a list in a test is one more place for N to be undercounted. + """ + import aipass.flow.apps as flow_apps + + root = Path(flow_apps.__file__).parent + names = set() + for source in sorted(root.rglob("*.py")): + if "__pycache__" in source.parts or ".archive" in source.parts: + continue + rel = source.relative_to(root).with_suffix("") + parts = [p for p in rel.parts if p != "__init__"] + names.add(".".join(["aipass.flow.apps", *parts])) + return sorted(names) + + +def _run_world(world: str, control: str, body: str) -> subprocess.CompletedProcess: + script = _PRELOAD + world + control + body + return subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=300, + ) + + +def _flow_modules_by_import_machinery() -> list[str]: + """The same set, derived through importlib's finder instead of the filesystem. + + A SECOND MECHANISM, on purpose. ``_flow_modules`` globs and manipulates + strings to undo ``__init__`` nesting; this asks ``pkgutil`` — the machinery + that decides what a module IS — and imports nothing. Re-running the same + rglob logic twice would be the table-written-twice failure: two copies of one + belief agree with each other no matter how wrong they are. + """ + import pkgutil + + import aipass.flow.apps as flow_apps + + def walk(path: Path, prefix: str) -> set[str]: + found = set() + for info in pkgutil.iter_modules([str(path)]): + name = f"{prefix}.{info.name}" + found.add(name) + if info.ispkg: + found |= walk(path / info.name, name) + return found + + root = Path(flow_apps.__file__).parent + names = walk(root, "aipass.flow.apps") | {"aipass.flow.apps"} + return sorted(name for name in names if ".archive" not in name) + + +@pytest.fixture(scope="module") +def flow_modules() -> list[str]: + """Every flow module, with the count DERIVED rather than lower-bounded. + + @seedgo nominated this file for the SHORT-TABLE species on 2026-08-31 + (@trigger's find, a ruling change rather than new code): the guard here was + ``len(modules) > 40``, which catches a collector blinded entirely and misses + one that drops a SINGLE entry. The table stays non-empty, every surviving + module passes, and the sweep is quietly one module lighter — an empty run + looks odd, a short one looks normal. + + Ruled on rather than inherited, which is what the nomination asks for. The + table is NOT legitimately variable here: the same tree is walked on every + host, so there is an exact answer and it is worth asserting. Both mechanisms + are compared as SETS rather than counts, because two collectors that disagree + should say WHICH module, not just how many. + """ + modules = _flow_modules() + expected = _flow_modules_by_import_machinery() + + assert set(modules) == set(expected), ( + "the filesystem walk and importlib's finder disagree about which modules exist. " + f"Only in the walk: {sorted(set(modules) - set(expected))}; " + f"only in the finder: {sorted(set(expected) - set(modules))}. One of them is " + "dropping entries, and a sweep built on the short list would pass while covering less." + ) + assert len(modules) > 40, ( + f"both mechanisms agree on only {len(modules)} modules — they are agreeing about a " + "tree that is not there, which set equality alone cannot catch" + ) + return modules + + +class TestTheModuleCountIsDerivedFromTwoMechanisms: + """The short-table cure's own control, added because a mutant walked past it. + + @seedgo's nomination replaced ``len(modules) > 40`` with set equality against + a second derivation. A mutant then pointed the fixture at ``_flow_modules`` + for BOTH operands and every pin stayed green: two copies of one belief agree + with each other however wrong they are, and the equality assertion becomes a + tautology that reads like a cross-check. The docstring warned about exactly + this while nothing enforced it — a comment is not a control. + """ + + def test_the_two_derivations_are_different_code_paths(self): + """Asserted structurally, because behaviour cannot tell a copy from a check.""" + import ast + import inspect + + # The DOCSTRING AND COMMENTS ARE STRIPPED FIRST, and this is the third + # time in three rounds that a structural check has reddened on its own + # explanation: the function's docstring names ``rglob`` precisely to warn + # against it. A check that cannot tell the warning from the violation + # will always convict the most careful author. Parsing gets the executable + # body; comments are dropped by the tokenizer on the way. + tree = ast.parse(inspect.getsource(_flow_modules_by_import_machinery).strip()) + body = tree.body[0].body + if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant): + body = body[1:] + finder_source = "\n".join(ast.unparse(node) for node in body) + + assert "pkgutil" in finder_source, ( + "the second derivation no longer goes through importlib's finder — if it " + "globs the filesystem like the first one, the two agree by construction" + ) + assert "_flow_modules(" not in finder_source, ( + "the second derivation calls the first — the set equality in the fixture is " + "then comparing a value with itself" + ) + assert "rglob" not in finder_source, "the second derivation is globbing, which is the first mechanism" + + def test_the_fixture_compares_one_against_the_other(self): + """And that the fixture actually uses both, rather than one twice.""" + import inspect + + fixture_source = inspect.getsource(flow_modules.__wrapped__) + + assert "_flow_modules()" in fixture_source and "_flow_modules_by_import_machinery()" in fixture_source, ( + "the fixture no longer derives its expectation from the second mechanism: " + fixture_source + ) + + def test_both_mechanisms_agree_on_a_known_module(self): + """Control for the pair: two empty collectors would also be set-equal.""" + by_walk = set(_flow_modules()) + by_finder = set(_flow_modules_by_import_machinery()) + + for expected in ("aipass.flow.apps.handlers.repo_root", "aipass.flow.apps.handlers.json.json_handler"): + assert expected in by_walk, f"the filesystem walk lost a module that is demonstrably there: {expected}" + assert expected in by_finder, f"the finder lost a module that is demonstrably there: {expected}" + + +class TestEveryModuleImportsWithoutACwd: + """The import fan, in both worlds, with the world's own liveness asserted.""" + + IMPORT_BODY = """ +import importlib +import sys +import traceback + +dead = [] +for name in {names!r}: + try: + importlib.import_module(name) + except OSError: + tb = traceback.extract_tb(sys.exc_info()[2]) + site = "unknown" + for fr in tb: + if "aipass" in fr.filename and "flow" in fr.filename: + site = fr.filename + ":" + str(fr.lineno) + dead.append(name + " -> " + site) + except Exception: + pass # not this file's question + +print("DEAD: " + str(len(dead))) +for entry in dead: + print(" " + entry) +print("SWEPT: " + str(len({names!r}))) +""" + + def test_world_a_ntpath_emulation_kills_no_flow_import(self, flow_modules): + """A raw ``Path(__file__).resolve()`` anywhere on the import fan reds this.""" + result = _run_world(_WORLD_A, _RESOLVE_CONTROL, self.IMPORT_BODY.format(names=flow_modules)) + + assert result.returncode == 0, result.stdout + result.stderr + assert "RESOLVE_DIES: YES" in result.stdout, ( + "the ntpath world did not arm — every assertion below it would be vacuous.\n" + result.stdout + ) + assert "DEAD: 0" in result.stdout, result.stdout + assert f"SWEPT: {len(flow_modules)}" in result.stdout, result.stdout + + def test_world_b_denied_realpath_kills_no_flow_import(self, flow_modules): + """Harsher, and the only world that convicts ``inspect.stack()``.""" + result = _run_world(_WORLD_B, _STACK_CONTROL, self.IMPORT_BODY.format(names=flow_modules)) + + assert result.returncode == 0, result.stdout + result.stderr + assert "STACK_DIES: YES" in result.stdout, ( + "world B did not arm — inspect.stack() survived it, so nothing here convicts that call.\n" + result.stdout + ) + assert "DEAD: 0" in result.stdout, result.stdout + + +class TestTheAuditLineSurvivesTheWorldItLogsIn: + """``log_operation`` is reached at runtime, so no import probe covers it. + + The stack it walks is the CALLER'S, so the shape that convicts it is a + ```` frame — a routed subprocess, a hook, anything exec'd — which is + precisely what @drone's router produces when it invokes flow. + """ + + BODY = """ +import os +import tempfile + +os.environ["AIPASS_TEST_LOG_DIR"] = tempfile.mkdtemp() +from aipass.flow.apps.handlers.json import json_handler + +json_handler.FLOW_JSON_DIR = __import__("pathlib").Path(tempfile.mkdtemp()) + +g = {"jh": json_handler, "name": None} +try: + exec(compile("name = jh._get_caller_module_name()", "", "exec"), g) + print("CALLER_NAME: " + str(g["name"])) +except OSError as exc: + print("CALLER_NAME DIED: " + type(exc).__name__) + +try: + exec(compile("jh.log_operation('dead_cwd_probe', {'k': 1})", "", "exec"), g) + print("LOG_OPERATION: SURVIVED") +except OSError as exc: + print("LOG_OPERATION DIED: " + type(exc).__name__) +""" + + def test_log_operation_survives_a_string_frame_with_realpath_denied(self): + result = _run_world(_WORLD_B, _STACK_CONTROL, self.BODY) + + assert result.returncode == 0, result.stdout + result.stderr + assert "STACK_DIES: YES" in result.stdout, ( + "world B did not arm — this test would pass against the uncured call.\n" + result.stdout + ) + assert "LOG_OPERATION: SURVIVED" in result.stdout, result.stdout + assert "CALLER_NAME DIED" not in result.stdout, result.stdout + # It must still ANSWER, not merely not-crash: returning "unknown" for + # every caller would satisfy the line above and destroy the audit trail. + assert "CALLER_NAME: " in result.stdout, ( + "the caller name stopped being read from the frame: " + result.stdout + ) + + +def expected_route_without_cure(has_accessor: bool) -> str: + """What removing ``_ACCESSOR_CURE`` must do to the real ``Path.resolve`` route. + + A PLAIN FUNCTION, fed synthetic values, because the alternative is a branch + only one interpreter can execute. @commons' round-5 separation: keep the + JUDGEMENT apart from the WORLD, and every row becomes reachable on any host — + including the row this machine cannot produce, which is the only row whose + inversion would otherwise sail through a green suite. + + Args: + has_accessor: Whether this interpreter's ``pathlib`` still carries + ``_NormalAccessor`` — true on <=3.10, false from 3.11. + + Returns: + ``"ROUTE_INERT"`` where the cure is load-bearing, ``"ROUTE_ARMED"`` where + it patches nothing and removing it can change nothing. + """ + return "ROUTE_INERT" if has_accessor else "ROUTE_ARMED" + + +class TestTheRouteExpectationIsReachableOnAnyHost: + """Both rows of the judgement above, on whichever interpreter runs this. + + Round 7's M9: inverting the ``has_accessor`` branch of the live test left the + suite green, because no interpreter here can enter it. That is @ai_mail's + unfalsifiable-row species inside the pin written to answer a red about the + same species. Naming the choice is what makes it convictable — @memory's + ``_world_for()``, applied one file over. + """ + + def test_an_interpreter_with_an_accessor_needs_the_cure(self): + """The 3.10 row. Unreachable as a live branch here; reachable as a value.""" + assert expected_route_without_cure(True) == "ROUTE_INERT", ( + "with a real _NormalAccessor the cure is the only thing rebinding the " + "captured copy, so removing it must leave the route unarmed" + ) + + def test_an_interpreter_without_one_cannot_notice_the_cure(self): + """The 3.11+ row, which is also the row this machine measures live.""" + assert expected_route_without_cure(False) == "ROUTE_ARMED" + + def test_the_two_rows_disagree(self): + """Control: a judgement returning one constant would pass both rows above.""" + assert expected_route_without_cure(True) != expected_route_without_cure(False), ( + "the judgement is not keyed on its input — then the live test below is " + "asserting the same thing on every interpreter and the 3.10 row is decoration" + ) + + +class TestTheWorldArmsOnAPreCapturedAccessor: + """The 3.10 row, made falsifiable on an interpreter that has no accessor. + + ``_ACCESSOR_CURE`` is a no-op on 3.11+, which removed ``_NormalAccessor``. A + cure whose only evidence is a CI leg this machine cannot run is a row nobody + here can contradict — @ai_mail's round-5 species, where a table entry no + local platform can falsify enters silently. So the 3.10 CONSTRUCTION is + rebuilt here and both directions are asserted against it. + + ROUND 7, AND THIS IS THE PART WORTH READING. Two of these pins went red on + the Windows runner, both reporting ``ACCESSOR_DIES: YES`` where they demanded + NO. Nothing about the cure was wrong; the PROBES were. They let the emulated + accessor capture the live ``os.path.realpath``, then asked "did a later patch + reach it?" by denying ``os.getcwd`` and reading raise/no-raise. That + discriminates on posix, where the captured function ignores the cwd for an + absolute path, so a raise can only mean the patch landed. On nt + ``os.path`` IS ``ntpath`` and ``ntpath.realpath`` reads ``os.getcwd`` + UNCONDITIONALLY — this branch's own round-4 headline, turned back on the + instrument that quoted it — so the ORIGINAL raises too and RAISED stops + discriminating. + + @memory measured the identical species on four of their own reds and + published the rule the fleet adopted: AN INSTRUMENT MUST NOT IMPORT BEHAVIOUR + IT IS NOT TESTING. Their three consequences, each verified here rather than + imported on their word: + + 1. emulate BOTH platforms or neither — a table with one emulated row and one + bare row is host-dependent in the half nobody thought about, and "no + emulation" reads as posix only while the host is posix; + 2. build an emulation from the dialect module BY NAME (``posixpath``, + ``ntpath``), never from ``os.path``, which IS the host; + 3. when a probe asks "did my patch reach X", let X have captured a SENTINEL — + otherwise the original's own platform behaviour answers the question. + + Their litmus, and every accessor probe below now runs it: exercise the probe + under the OPPOSITE platform's emulation and require the verdict not to move. + + Built to the four traps @memory and @seedgo paid for in round 6, each of + which makes this vacuously green: + + * ``staticmethod`` — a plain function on a class arrives bound and eats the + path into ``self``, so the patch resolves the accessor OBJECT and a + raise-shaped pin stays green for the wrong reason; + * exercised through an INSTANCE (3.10's ``Path.resolve`` calls + ``self._accessor.realpath(self, strict=strict)``); a class-level read never + binds and never shows the trap; + * probed with an ABSOLUTE path — ``posixpath.realpath`` reads the cwd for a + RELATIVE one whatever else is patched, so a relative probe convicts on the + path's shape and looks like a working world; + * captured EAGERLY at class creation (@seedgo's M9) — a lazy capture reads + the already-patched module attribute and the whole shape becomes a no-op + that proves nothing. + """ + + # ------------------------------------------------------------------ + # The two hosts, built from the dialect modules BY NAME (@memory rule 2) + # ------------------------------------------------------------------ + # ``os.path`` is the HOST: posixpath here, ntpath on the Windows runner. An + # emulation assembled out of it emulates whatever it is already running on, + # which is how a row labelled posix measured nt and reported it as fact. + POSIX_HOST = """ +import os +import posixpath + +# Bound BEFORE the rebinding below, and this is not a style choice. On a posix +# host ``os.path IS posixpath``, so assigning ``os.path.realpath`` assigns +# ``posixpath.realpath`` — a body that then looked the name up again would call +# itself forever. Measured the hard way: the first version of this constant +# recursed 997 frames deep. The same identity that makes an os.path-built +# emulation measure the host makes it eat itself. +_posixpath_realpath = posixpath.realpath + + +def _posix_realpath(path, *a, **k): + # posixpath.realpath reads the cwd ONLY for a relative path. + return _posixpath_realpath(path, *a, **k) + + +os.path.realpath = _posix_realpath + +# The probe path, IN THIS EMULATION'S DIALECT. Round 8: it used to be built from +# ``os.sep`` and ``pathlib.__file__`` — the HOST's dialect — and on the Windows +# runner that is ``\\definitely\\not\\here``, which posixpath reads as RELATIVE. +# posixpath.realpath reads the cwd for a relative path on every platform, so the +# posix row convicted for the path's SHAPE and reported it as "the emulation is +# not posix-shaped". The emulation was fine. Its INPUT was the host's. +ABSOLUTE = "/definitely/not/here" +""" + + NT_HOST = r""" +import os +import ntpath + + +def _nt_realpath(path, *a, **k): + # CPython's ntpath.realpath takes ``cwd = os.getcwd()`` while picking its + # str/bytes prefixes — on the first lines, before it asks whether the path + # is even absolute. Spelled out rather than aliased to ``ntpath.realpath``, + # because off Windows that name is a WRAPPER that returns ``abspath(path)``, + # which reads the cwd only for a relative path: aliasing it would emulate + # this host wearing an nt label, the exact defect this constant prevents. + # + # WRAPPER, not alias, and @skills paid for the distinction by source read: + # ntpath.py defines ``realpath`` twice and picks at import on whether + # ``nt._getfinalpathname`` exists, so off Windows you get a fallback ``def`` + # rather than a rebinding. Measured here on 3.12.3 — + # ``ntpath.realpath is ntpath.abspath`` is FALSE. The consequence is what + # matters: anyone checking this edge with an ``is`` test gets a green that + # means nothing. Behaviour-equality is the claim; identity is not. + os.getcwd() + return ntpath.normpath(path) + + +os.path.realpath = _nt_realpath + +# Drive-qualified, because that is what "absolute" means in this dialect. A +# posix-shaped literal passes ``ntpath.isabs`` but is DRIVE-RELATIVE — @memory +# measured ``ntpath.realpath('/tmp')`` returning ``D:\tmp`` on the runner — so +# ``isabs`` alone is not enough to call a literal absolute here. +# +# THE ENCLOSING CONSTANT IS RAW, and it has to be: a non-raw triple-quote eats +# the escapes before the child ever sees them, so ``\not\here`` arrived as a +# NEWLINE and the emitted source was an unterminated string. The instrument's +# INPUT was corrupted by the language the instrument is written in — the same +# shape as the round-8 defect itself, one layer down. +ABSOLUTE = r"C:\definitely\not\here" +""" + + # Both hosts, for the litmus. Named so a failing parametrisation says which + # platform moved. + HOSTS = {"posix": POSIX_HOST, "nt": NT_HOST} + + # The RUNNER, as opposed to the emulated host: the platform the test process + # itself is executing on. Round 7's litmus varied the emulated host and held; + # round 8 reddened anyway, because three probes read the RUNNER — ``os.sep`` + # and ``pathlib.__file__`` — to build their input. Varying the emulated world + # cannot see that. This constant makes the runner a variable too. + # + # It fakes only what a probe can read to build a path. It is NOT a world and + # must never be used as one. + WINDOWS_RUNNER = r""" +import ntpath +import os +import pathlib + +os.path = ntpath +os.sep = "\\" +pathlib.__file__ = r"D:\a\AIPass\AIPass\.venv\Lib\pathlib.py" +""" + + # The counterpart, and round 9 is why it exists. A single Windows-shaped + # runner fake CANNOT ARM on a Windows runner: the host already is what the + # fake supplies, so the faked and unfaked runs are byte-identical and every + # comparison built on the difference passes for free. @seedgo's one-layer + # rule — host == emulated is not two layers — landing in the file whose + # round-8 reply flagged it for everybody else. + # + # So the runner varies toward BOTH platforms. On any host at least one of + # these differs from it, which is @memory's "emulate both platforms or + # neither" applied one dimension over — to the RUNNER, where round 8 applied + # it only to the emulated world. + POSIX_RUNNER = """ +import os +import pathlib +import posixpath + +os.path = posixpath +os.sep = "/" +pathlib.__file__ = "/usr/lib/python3/pathlib.py" +""" + + # Both runner shapes. Keyed by name so a failure says which one moved. + RUNNERS = {"posix": POSIX_RUNNER, "windows": WINDOWS_RUNNER} + + # For compositions that install no host at all. The literal is deliberately + # built in the HOST's dialect here: with no emulation in play, "absolute" + # means whatever this interpreter means by it, and saying so beats a + # posix literal that would be drive-relative on Windows. + NO_HOST = """ +import os + +ABSOLUTE = os.path.join(os.sep, "definitely", "not", "here") +""" + + # ------------------------------------------------------------------ + # The 3.10 construction, with a SENTINEL where the live function was + # ------------------------------------------------------------------ + # Shape byte-for-byte as CPython 3.10 Lib/pathlib.py:358 + # ``realpath = staticmethod(os.path.realpath)``, captured when the class body + # executes — pathlib's first import, BEFORE any world is installed. + # + # What it captures is NOT ``os.path.realpath``. The question these probes ask + # is "did a later patch REPLACE this attribute", and the only honest way to + # ask it is to make the pre-patch value do nothing observable: the sentinel + # returns its argument and touches no filesystem, no cwd, no path module. Any + # raise afterwards is then the patch's doing, on any platform. Capturing the + # real function instead is what reddened this file on Windows. + SHAPE = """ +import os +import pathlib + + +def _sentinel_captured(path, *a, **k): + return "CAPTURED" + + +def _sentinel_moved(path, *a, **k): + return "MOVED" + + +_source_realpath = _sentinel_captured + + +class _Accessor: + realpath = staticmethod(_source_realpath) # captured EAGERLY, as 3.10 does + + +# The source name MOVES after the class body has run. An eager capture kept the +# value and still answers CAPTURED; a lazy one follows the name and answers +# MOVED. @trigger's escape, and it closes a hole their correction found in my +# round-7 shape: a sentinel is stale-proof, so a LAZY wrapper AROUND a sentinel +# returns exactly what an eager capture of it returns, and the eagerness pin goes +# quietly dark while looking healthy. The durable form of an identity check is a +# difference you CONSTRUCT. No platform behaviour appears anywhere in this. +_source_realpath = _sentinel_moved + + +_accessor = _Accessor() +""" + + # Called through the instance, with an absolute path, exactly as 3.10's + # Path.resolve reaches it. + PROBE = """ +try: + _accessor.realpath(ABSOLUTE) + print("ACCESSOR_DIES: NO") +except OSError: + print("ACCESSOR_DIES: YES") +""" + + # The same construction, but PUBLISHED ON pathlib under the name 3.10 uses — + # so ``_WORLD_A`` runs VERBATIM against it, hasattr and all, and the thing + # under test is the constant that ships rather than a restatement of it. + PUBLISHED_SHAPE = """ +import os +import pathlib + + +def _sentinel_captured(path, *a, **k): + return "CAPTURED" + + +def _sentinel_moved(path, *a, **k): + return "MOVED" + + +_source_realpath = _sentinel_captured + + +class _NormalAccessor: + realpath = staticmethod(_source_realpath) # captured EAGERLY, as 3.10 does + + +# The source name MOVES after the class body has run. An eager capture kept the +# value and still answers CAPTURED; a lazy one follows the name and answers +# MOVED. @trigger's escape, and it closes a hole their correction found in my +# round-7 shape: a sentinel is stale-proof, so a LAZY wrapper AROUND a sentinel +# returns exactly what an eager capture of it returns, and the eagerness pin goes +# quietly dark while looking healthy. The durable form of an identity check is a +# difference you CONSTRUCT. No platform behaviour appears anywhere in this. +_source_realpath = _sentinel_moved + + +pathlib._NormalAccessor = _NormalAccessor +_accessor = _NormalAccessor() +""" + + BARE_MODULE_PATCH_ONLY = """ +_real = os.path.realpath + + +def _denied_realpath(path, *a, **k): + os.getcwd() + return _real(path, *a, **k) + + +os.path.realpath = _denied_realpath + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd +""" + + # The shipped cure, written against the emulated accessor rather than + # pathlib's, because 3.11+ has none to patch. + EMULATED_CURE = """ +_Accessor.realpath = staticmethod(_denied_realpath) +""" + + # Eagerness, measured as a value rather than as a consequence. Takes a bare + # string, not a path: there is no filesystem, no cwd and no dialect in this + # question, so none should be able to answer it. + EAGERNESS_PROBE = """ +print("EAGERNESS:", _accessor.realpath("probe")) +""" + + # The real route: no emulated accessor anywhere, just this interpreter's own + # pathlib. ``import pathlib`` comes FIRST so a 3.10 accessor takes its copy + # BEFORE the world is installed — which is the only ordering under which the + # cure is load-bearing, and the ordering every real import fan has. + REAL_ROUTE_PROBE = """ +import os +from pathlib import Path as _RoutePath + +_abs = os.path.join(os.sep, "definitely", "not", "here") +try: + _RoutePath(_abs).resolve() + print("ROUTE_INERT") +except OSError: + print("ROUTE_ARMED") +""" + PATHLIB_FIRST = "import pathlib\n" + + @staticmethod + def _run(script: str) -> str: + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout + + @staticmethod + def _run_or_reason(script: str) -> tuple[str, str | None]: + """Run a child, returning ``(stdout, None)`` or ``(stdout, reason)``. + + ``_run`` asserts the child exited cleanly, which is right for a probe + whose world this branch controls. It is wrong for a RUNNER fake: a host + this file was not written on may refuse one, and failing there reports + somebody else's platform as a flow defect. @spawn's round-8 shape — + say UNAVAILABLE with the child's own reason and skip that row, never + fail on an interpreter you do not have. + """ + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=120) + if result.returncode != 0: + return result.stdout, (result.stderr.strip().splitlines() or ["no diagnostic"])[-1] + return result.stdout, None + + def _runner_verdicts(self, body: str, runners: dict[str, str]) -> dict[str, str]: + """Every verdict for *body*, once bare and once under each runner fake. + + RAISES on a row that could not be measured, rather than returning a dict + that quietly has fewer keys than it should. That is deliberate: round 9's + lesson is that a row which cannot speak must not be mistaken for a row + that agreed, and an assertion sitting beside the collector was + unconvictable — no runner fake fails on this host, so deleting it changed + nothing. Making the refusal part of the collector's contract lets a + synthetic broken runner convict it here. + + Args: + body: The composed child script whose verdict is being compared. + runners: Runner fakes to prepend, keyed by name. + + Returns: + Verdicts keyed by runner name, plus ``""``. + + Raises: + AssertionError: If any runner fake could not be installed. + """ + bare, reason = self._run_or_reason(body) + assert reason is None, f"the unfaked run failed, so nothing here is measurable: {reason}" + + verdicts = {"": bare.strip()} + for runner in sorted(runners): + out, why = self._run_or_reason(runners[runner] + body) + assert why is None, ( + f"the {runner!r} runner fake could not be installed, so that row was not " + "measured at all. A row that cannot speak is not a row that agreed — this " + f"is a fact about this host, not a verdict about the probes: {why}" + ) + verdicts[runner] = out.strip() + return verdicts + + @staticmethod + def _literal_from(host: str) -> str: + """The probe path a host publishes, read out of the constant itself. + + Parsed rather than restated: a pin that hard-codes the literal it is + checking stops noticing when the constant changes, which is the failure + mode of every table written twice. + """ + line = next(entry for entry in host.splitlines() if entry.startswith("ABSOLUTE = ")) + return ast.literal_eval(line.split("=", 1)[1].strip()) + + @staticmethod + def _world_without_cure() -> str: + """``_WORLD_A`` with the shipped cure sliced out, read off the constants. + + Both operands come from the module rather than being restated, so a + control built on this cannot quietly start proving a different four lines + than the ones that ship. + """ + world = _WORLD_A.replace(_ACCESSOR_CURE, "") + assert world != _WORLD_A, "the cure is not a substring of the world — this slice is not slicing anything" + return world + + # ------------------------------------------------------------------ + # The defect direction: a bare module patch must not reach the capture + # ------------------------------------------------------------------ + + @pytest.mark.parametrize("host", sorted(HOSTS)) + def test_a_bare_module_attribute_patch_does_not_reach_a_captured_accessor(self, host): + """CI's Python 3.10 failure, reproduced on this machine — under BOTH hosts. + + This is the defect: the denial lands on ``os.path.realpath`` and the + accessor keeps calling the copy it took at import. Every pin that + depended on the world would have been vacuously green. + + Round 7 added the host parametrisation and the sentinel. Before them this + pin read ``ACCESSOR_DIES: YES`` on the Windows runner and reported it as + "a bare module-attribute patch reached the captured accessor" — which was + false. Nothing had reached anything; the captured ``ntpath.realpath`` was + raising on its own unconditional ``os.getcwd()``. + """ + out = self._run(self.HOSTS[host] + self.SHAPE + self.BARE_MODULE_PATCH_ONLY + self.PROBE) + + assert "ACCESSOR_DIES: NO" in out, ( + f"under the {host} host a bare module-attribute patch appeared to reach the " + "captured accessor. Either the patch really did land — and then the 3.10 CI " + "failure has no mechanism and this whole cure is unexplained — or the capture " + "is not a sentinel and the platform's own realpath is answering instead: " + out + ) + + @pytest.mark.parametrize("host", sorted(HOSTS)) + def test_the_published_shape_captures_eagerly(self, host): + """Trap (d) as a control on the PUBLISHED half, where it was missing. + + Found by mutation in round 6, not by reading: making ``PUBLISHED_SHAPE`` + capture lazily left the whole class green. A lazy capture follows the + patched module attribute, so the arming pin below passes WITHOUT + ``_ACCESSOR_CURE`` doing anything — it stops distinguishing an accessor + patch from a module patch, which is @seedgo's M9 one level along. + + So: the shipped world with its cure REMOVED must leave this shape alive, + under either host. + """ + out = self._run(self.HOSTS[host] + self.PUBLISHED_SHAPE + self._world_without_cure() + self.PROBE) + + assert "ACCESSOR_DIES: NO" in out, ( + f"under the {host} host a module-only patch appeared to reach the published " + "accessor, so either it is not capturing eagerly or the capture is not a " + "sentinel — and the arming pin below is green for the wrong reason: " + out + ) + + @pytest.mark.parametrize("host", sorted(HOSTS)) + def test_the_published_shape_is_inert_without_the_world(self, host): + """Control: the shape alone must not convict, or the arming pin is free.""" + out = self._run(self.HOSTS[host] + self.PUBLISHED_SHAPE + self.PROBE) + + assert "ACCESSOR_DIES: NO" in out, ( + f"under the {host} host the accessor died with no world installed at all — " + "the arming pin is measuring something other than the world: " + out + ) + + # ------------------------------------------------------------------ + # The cure direction: the shipped world must arm the capture + # ------------------------------------------------------------------ + + @pytest.mark.parametrize("host", sorted(HOSTS)) + def test_the_shipped_world_arms_through_a_pre_captured_accessor(self, host): + """The cure, exercised as ``_WORLD_A`` VERBATIM against a 3.10-shaped accessor. + + Everything above establishes that the construction reproduces CI; this + runs the SHIPPED world text against it and asserts it arms — so deleting + ``_ACCESSOR_CURE`` reds here with CI's own sentence, on an interpreter + that has no accessor of its own. + """ + out = self._run(self.HOSTS[host] + self.PUBLISHED_SHAPE + _WORLD_A + self.PROBE) + + assert "ACCESSOR_DIES: YES" in out, ( + f"under the {host} host the ntpath world did not arm — the shipped world's " + "denial did not reach a pre-captured accessor, which is exactly the Python " + "3.10 failure on 8550ed10: " + out + ) + + @pytest.mark.parametrize("host", sorted(HOSTS)) + def test_patching_the_accessor_arms_the_same_world(self, host): + """The same direction through the local emulation, without pathlib in it. + + Kept alongside the pin above because it isolates the CURE from the + ``hasattr`` lookup: if ``pathlib._NormalAccessor`` ever stops being the + name the world reaches for, this one still says whether patching an + accessor arms the world at all. + """ + out = self._run(self.HOSTS[host] + self.SHAPE + self.BARE_MODULE_PATCH_ONLY + self.EMULATED_CURE + self.PROBE) + + assert "ACCESSOR_DIES: YES" in out, ( + f"under the {host} host the accessor patch did not arm the world it exists to arm: " + out + ) + + # ------------------------------------------------------------------ + # The litmus, stated as its own pin rather than left implicit + # ------------------------------------------------------------------ + + def test_no_accessor_verdict_moves_between_the_two_hosts(self): + """@memory's litmus, run over every direction at once. + + The parametrisation above already runs each probe on both hosts, but it + asserts a CONSTANT per probe. This asserts the weaker, more general thing + the constants are an instance of: the platform must not be able to change + any verdict. If someone later relaxes one expectation to a per-host value, + this pin is what refuses. + """ + directions = { + "bare-patch": self.SHAPE + self.BARE_MODULE_PATCH_ONLY + self.PROBE, + "published-no-cure": self.PUBLISHED_SHAPE + self._world_without_cure() + self.PROBE, + "published-bare": self.PUBLISHED_SHAPE + self.PROBE, + "published-cured": self.PUBLISHED_SHAPE + _WORLD_A + self.PROBE, + "emulated-cure": self.SHAPE + self.BARE_MODULE_PATCH_ONLY + self.EMULATED_CURE + self.PROBE, + } + moved = { + name: (self._run(self.POSIX_HOST + body).strip(), self._run(self.NT_HOST + body).strip()) + for name, body in directions.items() + } + disagreed = {name: pair for name, pair in moved.items() if pair[0] != pair[1]} + + assert disagreed == {}, ( + "an accessor verdict changed with the emulated platform, so the probe is " + f"measuring the host rather than the patch: {disagreed}" + ) + + def test_no_verdict_moves_when_the_runner_changes_shape(self): + """ROUND 8's instrument, cured of ROUND 9's blindness. + + Round 7's litmus varied the emulated HOST and held; round 8 reddened + anyway because two probes read the RUNNER — ``os.sep``, + ``pathlib.__file__`` — to build their input. So round 8 added a + Windows-shaped runner fake and required no verdict to move. + + THAT FAKE CANNOT ARM ON A WINDOWS RUNNER. The host already is what it + supplies, so faked and unfaked runs are byte-identical and the comparison + passes for free — measured on the windows-setup leg of 9bd2618b, where + the control below reported the two outputs equal character for character. + The litmus was DARK on exactly the platform it was written to catch, and + that is worse than the red that reported it: the red was one assertion, + the darkness was the whole instrument. + + HOST == FAKED IS ONE LAYER — @seedgo's rule, arriving in the file whose + round-8 reply passed it on to everyone else. The cure is @memory's + "emulate both platforms or neither" applied one dimension over: the + runner varies toward BOTH shapes, so on any host at least one fake + genuinely differs from it, and the bare run is compared against both. + """ + live_shape = self.SHAPE.replace("staticmethod(_source_realpath)", "staticmethod(os.path.realpath)") + directions = { + f"{host}:{name}": self.HOSTS[host] + body + for host in sorted(self.HOSTS) + for name, body in { + "bare-patch": self.SHAPE + self.BARE_MODULE_PATCH_ONLY + self.PROBE, + "published-cured": self.PUBLISHED_SHAPE + _WORLD_A + self.PROBE, + "published-no-cure": self.PUBLISHED_SHAPE + self._world_without_cure() + self.PROBE, + "live-capture": live_shape + self.BARE_MODULE_PATCH_ONLY + self.PROBE, + }.items() + } + + disagreed = {} + for name, body in directions.items(): + verdicts = self._runner_verdicts(body, self.RUNNERS) + if len(set(verdicts.values())) > 1: + disagreed[name] = verdicts + + assert disagreed == {}, ( + "a verdict changed when only the RUNNER changed. The world under test was " + "identical in every run, so something in the probe is reading the host to " + "build its input — most likely a path spelled with os.sep or derived from a " + f"module's __file__, which posixpath reads as relative on Windows: {disagreed}" + ) + + def test_a_runner_fake_that_cannot_install_is_refused_not_skipped(self): + """The UNAVAILABLE row, made reachable on a host where nothing is unavailable. + + No runner fake fails here, so the refusal branch is unreachable from any + real composition — the species that keeps recurring in this file, and a + mutant confirmed it: deleting the check left all 47 pins green. Cured by + feeding the collector a synthetic runner that cannot install, which is a + difference constructed rather than waited for. + + The distinction being defended: SKIPPING an unmeasurable row makes it + indistinguishable from a row that agreed, and every verdict-comparison + above would then be over a smaller set than it claims. + """ + broken = {"deliberately-broken": "raise RuntimeError('this runner fake cannot install here')\n"} + + with pytest.raises(AssertionError, match="was not measured at all"): + self._runner_verdicts(self.NO_HOST + self.PUBLISHED_SHAPE + self.PROBE, broken) + + def test_the_collector_reports_every_row_when_they_all_install(self): + """Control: a collector that always raised would pass the pin above for free.""" + verdicts = self._runner_verdicts(self.NO_HOST + self.PUBLISHED_SHAPE + self.PROBE, self.RUNNERS) + + assert set(verdicts) == {"", *self.RUNNERS}, ( + f"the collector dropped a row on a host where every fake installs: {verdicts}" + ) + + def test_at_least_one_runner_fake_changes_what_a_probe_reads(self): + """ROUND 9's red, cured with three states instead of a bare difference. + + This is the control for the litmus above, and it used to assert that the + Windows fake changed something. On Linux it does; on the Windows runner + it changes NOTHING, because the host already supplies exactly what the + fake supplies — and the assertion fired, reporting "the litmus is + vacuous" as though the instrument were broken. The instrument was fine on + that host and the CONTROL was the thing that could not survive being run + where its subject was already true. + + Three states now, and each is an OUTPUT rather than a silence: + + * CHANGED — the fake altered what a probe reads; + * ALREADY — the host is already this shape, with the host's own + ``os.name``/``os.sep`` printed as the reason, so a no-op and a broken + fake are different results; + * UNAVAILABLE — the fake could not be installed here, carrying the + child's own diagnostic (@spawn's shape: never fail on someone else's + host, and never stay quiet about it either). + + The assertion that cannot go dark: at least one fake must report CHANGED + on every host. Both fakes reporting ALREADY would mean the runner + dimension is unvaried, which is round 9 again in either direction. + """ + probe = """ +import os +import pathlib + +print("SEP:", repr(os.sep)) +print("HOST_BUILT:", repr(os.path.join(os.sep, "x"))) +print("PATHLIB_FILE_ABS_TO_POSIX:", __import__("posixpath").isabs(str(pathlib.__file__))) +""" + bare, bare_reason = self._run_or_reason(probe) + assert bare_reason is None, f"the unfaked probe itself failed, so nothing below is measurable: {bare_reason}" + + verdicts = {} + for runner in sorted(self.RUNNERS): + out, reason = self._run_or_reason(self.RUNNERS[runner] + probe) + if reason is not None: + verdicts[runner] = f"UNAVAILABLE: {reason}" + elif out != bare: + verdicts[runner] = "CHANGED" + else: + verdicts[runner] = f"ALREADY (this host is already {runner}-shaped: {bare.strip()!r})" + + changed = [runner for runner, state in verdicts.items() if state == "CHANGED"] + + assert changed, ( + "NO runner fake changed anything a probe reads, so the runner dimension is " + "unvaried on this host and the litmus above is passing for free. With one fake " + "per platform that should be impossible — unless both were refused, or both " + f"now supply what the host already had: {verdicts}" + ) + + @pytest.mark.parametrize("simulated_host", ["posix", "windows"]) + def test_the_runner_set_arms_on_either_kind_of_host(self, simulated_host): + """Round 9's species, made falsifiable on the one machine available. + + The pin above asserts that some runner fake changes something on THIS + host. That is necessary and it is not enough: it was true on Linux for + the whole of round 8, while being false on the runner that mattered. A + row only this host can satisfy is exactly the thing round 9 was. + + So both KINDS of host are simulated here and the requirement is checked + against each: whatever shape the runner turns out to be, some fake in + ``RUNNERS`` must still differ from it. Deleting either fake reds this on + Linux — which is the whole point, because deleting the posix one is the + round-8 state and no probe on this machine could previously tell. + + The simulated host is applied FIRST and the fake stacked on top, so the + stacked case (host == faked) is exercised deliberately rather than met by + accident on somebody's CI leg. + """ + host_prefix = "" if simulated_host == "posix" else self.WINDOWS_RUNNER + probe = """ +import os +import pathlib + +print("SEP:", repr(os.sep)) +print("HOST_BUILT:", repr(os.path.join(os.sep, "x"))) +print("PATHLIB_FILE_ABS_TO_POSIX:", __import__("posixpath").isabs(str(pathlib.__file__))) +""" + bare, reason = self._run_or_reason(host_prefix + probe) + assert reason is None, f"the {simulated_host}-shaped host could not run the probe at all: {reason}" + + states = {} + for runner in sorted(self.RUNNERS): + out, why = self._run_or_reason(host_prefix + self.RUNNERS[runner] + probe) + states[runner] = f"UNAVAILABLE: {why}" if why else ("CHANGED" if out != bare else "ALREADY") + + assert "CHANGED" in states.values(), ( + f"on a {simulated_host}-shaped runner NO fake in RUNNERS changes what a probe " + "reads, so the runner dimension collapses there and the litmus passes for " + "free. This is the round-9 failure reproduced: host == faked is one layer. " + f"{states}" + ) + + @pytest.mark.parametrize( + ("runner", "expected"), + [ + ("posix", {"SEP_IS_BACKSLASH": "False", "PATH_MODULE": "posixpath", "FILE_ABS_TO_POSIX": "True"}), + ("windows", {"SEP_IS_BACKSLASH": "True", "PATH_MODULE": "ntpath", "FILE_ABS_TO_POSIX": "False"}), + ], + ) + def test_each_runner_fake_overrides_every_host_read_a_probe_makes(self, runner, expected): + """A fake that covers SOME of the host reads is not a fake, it is a leak. + + Found by mutation and it survived the coarser pins: deleting the + ``pathlib.__file__`` line from ``POSIX_RUNNER`` left every other pin + green, because that fake still changes ``os.sep`` and ``os.path``, so + "at least one fake CHANGED something" was satisfied while one of the + three host reads still came from the host. On Linux the omission is + invisible — ``pathlib.__file__`` is already posix — and load-bearing on + the Windows runner, where it is the ``D:\\...`` path that reddened round 8. + + So each fake is checked ATTRIBUTE BY ATTRIBUTE, under the OPPOSITE + simulated host, against the three reads the probes in this file actually + make. Any read that still answers with the host's value is a hole the + coarse pins cannot see. + """ + opposite = self.WINDOWS_RUNNER if runner == "posix" else "" + probe = """ +import os +import pathlib +import posixpath + +# A derived boolean, not repr(os.sep): a backslash crossing a string boundary +# gets eaten by whichever quoting layer is least expected, which cost three +# separate repairs across rounds 8 and 9. Nothing here needs the character +# itself — only whether the fake supplied it. +print("SEP_IS_BACKSLASH", os.sep == chr(92)) +print("PATH_MODULE", os.path.__name__) +print("FILE_ABS_TO_POSIX", posixpath.isabs(str(pathlib.__file__))) +""" + out, reason = self._run_or_reason(opposite + self.RUNNERS[runner] + probe) + assert reason is None, f"the {runner} fake could not be installed over the opposite host: {reason}" + + reported = dict(line.split(" ", 1) for line in out.strip().splitlines()) + leaked = { + key: reported.get(key) for key, want in expected.items() if reported.get(key, "").strip("'\"") != want + } + + assert leaked == {}, ( + f"the {runner!r} runner fake did not override every host read a probe makes, so " + "those reads still answer with the host's value. On this machine that is " + "invisible for whichever reads the host already matches — which is exactly how " + f"it ships and reds on the other platform: {leaked} (full: {reported})" + ) + + def test_the_host_table_covers_every_dialect_it_is_built_from(self): + """@seedgo's SHORT-TABLE nomination, ruled on rather than inherited. + + Their checker flags five parametrisations here for reading + ``sorted(HOSTS)`` — a table computed at collection time whose only guard + is non-emptiness, which a collector dropping ONE entry still satisfies. + The ruling: the nomination is right about the shape and the table was + already guarded, just not anywhere their checker could see. Measured + before answering — deleting the ``nt`` row reds + ``test_the_two_hosts_are_genuinely_different_worlds``, which names both + constants directly. A guard that only a mutation run can find is not much + of a guard for the next reader, so it is spelled out here. + + DERIVED, not restated: the expected keys come from the dialect modules the + constants actually import, so adding a host without a dialect or dropping + a dialect without its host both red. Writing ``{"posix", "nt"}`` as a + literal would be the table written twice. + """ + named_after = {"posixpath": "posix", "ntpath": "nt"} + dialects = { + module: name + for name, host in self.HOSTS.items() + for module in ("posixpath", "ntpath") + if f"import {module}" in host + } + + assert dialects == {module: named_after[module] for module in dialects}, ( + "a host is registered under a name that does not match the dialect it is built " + f"from, so a parametrisation labelled posix may be emulating nt: {dialects}" + ) + assert set(dialects.values()) == set(self.HOSTS), ( + "a host in HOSTS is not built from a dialect module at all: " + f"{sorted(dialects.values())} vs {sorted(self.HOSTS)}" + ) + assert set(dialects) == {"posixpath", "ntpath"}, ( + "HOSTS no longer covers both path dialects. Every parametrisation over " + "sorted(HOSTS) silently loses a row when this shrinks, and the remaining rows " + f"still pass — @seedgo's SHORT-TABLE species: {sorted(dialects)}" + ) + + def test_the_two_hosts_are_genuinely_different_worlds(self): + """Control for the litmus: two identical hosts would pass it for free. + + Measures the one behaviour the whole round turns on — ``ntpath.realpath`` + reads the cwd for an ABSOLUTE path and ``posixpath.realpath`` does not — + through the emulations themselves, so a future edit that quietly makes + ``NT_HOST`` posix-shaped reds here instead of silently disarming the + litmus above. + """ + # ABSOLUTE comes from the host prefix, so each row is probed with a path + # its own dialect calls absolute. Building it here from ``os.sep`` is the + # round-8 defect: on the Windows runner that produced a backslash literal, + # posixpath read it as relative, and this pin reported "the posix + # emulation is not posix-shaped" about a perfectly good emulation. + probe = """ +import os + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd +try: + os.path.realpath(ABSOLUTE) + print("ABSOLUTE_READS_CWD: NO") +except OSError: + print("ABSOLUTE_READS_CWD: YES") +""" + assert "ABSOLUTE_READS_CWD: NO" in self._run(self.POSIX_HOST + probe), ( + "the posix emulation read the cwd for an absolute path — it is not posix-shaped" + ) + assert "ABSOLUTE_READS_CWD: YES" in self._run(self.NT_HOST + probe), ( + "the nt emulation did NOT read the cwd for an absolute path — it is this host " + "wearing an nt label, and every 'verdict did not move' result above is free" + ) + + # ------------------------------------------------------------------ + # The real accessor, on whichever interpreter actually has one + # ------------------------------------------------------------------ + + def test_the_cure_matches_the_accessor_this_interpreter_has(self): + """Both interpreters, one probe, no assertion about the host taken on faith. + + ROUND 7 RED, and the assertion text named its own fix: this used to be + ``assert not hasattr(pathlib, "_NormalAccessor")`` — "on 3.11+ the shipped + four lines patch nothing" — which is true on three CI legs and FALSE on + 3.10, where the cure is live by design. A pin that reds on the one + interpreter the cure exists for was measuring the emulation's redundancy, + not the cure. + + So it is a two-row table now, and the row that runs is measured LIVE on + whichever interpreter runs it: + + * every interpreter — the shipped world must ARM the real + ``Path.resolve`` route. This is the invariant the cure exists to hold, + and on 3.10 it is the REAL ``_NormalAccessor`` being exercised, not an + emulation of one. + * with a real accessor (<=3.10) — removing the cure must go INERT, which + is what makes it load-bearing rather than decorative. + * without one (3.11+) — removing the cure must change NOTHING, which is + what "inert here" actually means and is the honest version of the + deleted assertion. + + ``pathlib`` is imported FIRST so a 3.10 accessor takes its copy before the + world exists. Without that ordering the accessor captures the already + denied function, the cure looks unnecessary, and the row lies in the + direction of a passing test. + """ + import pathlib + + has_accessor = hasattr(pathlib, "_NormalAccessor") + armed = self._run(self.PATHLIB_FIRST + _WORLD_A + self.REAL_ROUTE_PROBE) + + assert "ROUTE_ARMED" in armed, ( + "the shipped world did not arm this interpreter's own Path.resolve — " + f"whatever the accessor situation is (has_accessor={has_accessor}), the world " + "is not hostile and every import fan above is vacuous: " + armed + ) + + without = self._run(self.PATHLIB_FIRST + self._world_without_cure() + self.REAL_ROUTE_PROBE) + expected = expected_route_without_cure(has_accessor) + + assert expected in without, ( + f"this interpreter reports has_accessor={has_accessor}, so removing " + f"_ACCESSOR_CURE was expected to leave the real resolve route {expected}. " + "With an accessor that means the cure is the only thing rebinding the captured " + "copy; without one it means the cure patches nothing and cannot be noticed. " + "Neither held: " + without + ) + + def test_the_shipped_cure_is_the_thing_being_emulated(self): + """The emulation must not drift from the constant that actually ships. + + Reads ``_ACCESSOR_CURE`` rather than restating it: an emulation that + proves a DIFFERENT four lines than the ones shipped proves nothing about + CI. + """ + assert "staticmethod(_denied_realpath)" in _ACCESSOR_CURE, ( + "the shipped cure no longer assigns a staticmethod — a plain function " + "arrives bound and resolves the accessor object instead of the path" + ) + assert "_NormalAccessor" in _ACCESSOR_CURE + assert "hasattr" in _ACCESSOR_CURE, ( + "the shipped cure is no longer guarded — it must be a no-op on 3.11+, which removed the accessor" + ) + # The emulation patches the same attribute name, the same way. + assert "staticmethod(_denied_realpath)" in self.EMULATED_CURE + + def test_the_capture_is_a_sentinel_and_not_the_live_function(self): + """The round-7 cure, pinned in the source rather than only in behaviour. + + The behavioural pins above would all pass again on Linux if someone + restored ``staticmethod(os.path.realpath)`` — that is exactly the state + this file shipped in, green here and red on Windows. So the sentinel is + asserted structurally too, in both shapes. + """ + for name, shape in (("SHAPE", self.SHAPE), ("PUBLISHED_SHAPE", self.PUBLISHED_SHAPE)): + assert "_sentinel_captured" in shape and "staticmethod(_source_realpath)" in shape, ( + f"{name} no longer captures a sentinel. If it captures the live " + "os.path.realpath, then on nt it captures ntpath.realpath, which reads " + "os.getcwd unconditionally — the ORIGINAL raises and 'it raised' stops " + "meaning 'the patch reached it'" + ) + assert "os.path.realpath" not in shape.split("class ")[0], ( + f"{name} reads os.path in the captured value — an instrument must not " + "import behaviour it is not testing (@memory, round 7)" + ) + + @pytest.mark.parametrize("shape_name", ["SHAPE", "PUBLISHED_SHAPE"]) + def test_the_capture_is_eager_measured_by_its_return_value(self, shape_name): + """@trigger's escape, adopted after their correction found the hole. + + I told them an identity check beats a behavioural one because + "consequences can be satisfied by accident; identities cannot". They + showed the missing clause: identities cannot be satisfied by accident, + but they CAN GO DARK when the thing whose identity you are checking stops + being able to differ. A sentinel is stale-proof by construction, so a lazy + wrapper AROUND the sentinel returns exactly what an eager capture of it + returns — and every behavioural eagerness pin in this class answered the + same either way. Measured, not conceded: mutating ``PUBLISHED_SHAPE`` to a + lambda around the sentinel left ``test_the_published_shape_captures_eagerly`` + GREEN. The pins that did catch it caught it on a string guard, which is + an accident of how the mutation was spelled. + + So eagerness is a constructed difference now. The source name is rebound + AFTER the class body runs: an eager capture kept the value and answers + CAPTURED, a lazy one follows the name and answers MOVED. Return-value, and + no filesystem, cwd or path dialect anywhere in the question. + """ + shape = getattr(self, shape_name) + out = self._run(shape + self.EAGERNESS_PROBE) + + assert "EAGERNESS: CAPTURED" in out, ( + f"{shape_name} did not keep the value bound at class creation — it is following " + "the source name, so it captures LAZILY and stops emulating 3.10, where the " + "accessor holds a copy taken at pathlib's first import: " + out + ) + + def test_the_eagerness_probe_can_report_the_other_answer(self): + """Control: a probe that can only print CAPTURED proves nothing. + + Builds the lazy shape explicitly and requires MOVED, so the pin above is + known to be reading a two-valued question rather than a constant. + """ + lazy = self.PUBLISHED_SHAPE.replace( + "realpath = staticmethod(_source_realpath)", + "realpath = staticmethod(lambda p, *a, **k: _source_realpath(p, *a, **k))", + ) + assert lazy != self.PUBLISHED_SHAPE, "the lazy variant is not replacing anything" + + out = self._run(lazy + self.EAGERNESS_PROBE) + + assert "EAGERNESS: MOVED" in out, ( + "a deliberately LAZY capture still answered CAPTURED — the source-name move is " + "not happening after the class body, so the pin above cannot distinguish " + "eager from lazy and is green by construction: " + out + ) + + def test_a_lazy_capture_would_prove_nothing(self): + """@seedgo's M9, pinned as a control on the emulation itself. + + If the shape captured LAZILY it would read the already-patched module + attribute, the bare-patch test would report DIES: YES, and the whole + construction would silently stop reproducing 3.10. + """ + lazy_shape = self.SHAPE.replace( + "realpath = staticmethod(_source_realpath) # captured EAGERLY, as 3.10 does", + "realpath = staticmethod(lambda p, *a, **k: os.path.realpath(p, *a, **k))", + ) + assert lazy_shape != self.SHAPE, "the lazy variant is not replacing anything" + + out = self._run(self.NO_HOST + lazy_shape + self.BARE_MODULE_PATCH_ONLY + self.PROBE) + + assert "ACCESSOR_DIES: YES" in out, ( + "a lazy capture did NOT follow the patched module attribute, so it is " + "not the no-op M9 describes and this control is not controlling: " + out + ) + + def test_each_host_probes_with_a_path_its_own_dialect_calls_absolute(self): + """ROUND 8's red, and the rule it generalises to. + + An instrument must not import behaviour it is not testing — and its + INPUTS are behaviour. Round 7 fixed the captured FUNCTION and left the + probe PATH built from ``os.sep`` and ``pathlib.__file__``, which are the + host's. On the Windows runner that yields ``\\definitely\\not\\here``; + posixpath reads it as RELATIVE, reads the cwd, and raises — so the posix + row convicted for the path's shape and announced "the posix emulation is + not posix-shaped" about an emulation that was doing its job. + + Measured on every platform, because both dialect modules import + everywhere. Asserted with ``isabs`` from the dialect module BY NAME, for + the same reason the emulations are built that way. + + THE TABLE IS NOT SYMMETRIC and pretending otherwise would hide the more + dangerous half: + + * posixpath REFUSES an nt literal — ``isabs`` is False, and that refusal + is the whole round-8 defect; + * ntpath ACCEPTS a posix literal — ``isabs`` is True — but treats it as + DRIVE-RELATIVE, which is @memory's ``ntpath.realpath('/tmp') -> + D:\\tmp`` on the runner. So an nt probe path must carry a DRIVE, and + ``isabs`` alone is not enough to call it absolute. + """ + import ntpath + import posixpath + + posix_literal = self._literal_from(self.POSIX_HOST) + nt_literal = self._literal_from(self.NT_HOST) + + assert posixpath.isabs(posix_literal), ( + f"the posix host probes with {posix_literal!r}, which posixpath does not call " + "absolute — posixpath.realpath reads the cwd for a relative path on EVERY " + "platform, so this row would convict for the path's shape" + ) + assert ntpath.isabs(nt_literal) and ntpath.splitdrive(nt_literal)[0], ( + f"the nt host probes with {nt_literal!r}, which carries no drive — ntpath " + "resolves a driveless path against the current drive, so the row measures the " + "runner's volume rather than the emulation" + ) + + # The negative half: this is what went wrong, stated as a fact rather + # than as history, so it reds if anyone reintroduces a host-built path. + assert not posixpath.isabs(nt_literal), ( + "an nt-shaped literal is absolute to posixpath — then the round-8 defect had " + "no mechanism and this pin is not guarding what it claims" + ) + assert not ntpath.splitdrive(posix_literal)[0], ( + "a posix literal carries a drive — the drive-relative asymmetry above is not real" + ) + + @pytest.mark.parametrize("host", sorted(HOSTS)) + def test_each_host_publishes_a_literal_and_never_computes_one(self, host): + """@trigger's round-9 offer, taken: a literal is immune, host-derived is not. + + Round 8 cured the two probe paths that were built from ``os.sep`` and + ``pathlib.__file__``, but nothing stopped the NEXT edit from computing one + from the host again — and a computed path is the round-8 defect returning + under a different spelling. ``_literal_from`` already refuses one, but it + refuses by raising out of ``ast.literal_eval`` with "malformed node", + which names no species and teaches the next reader nothing. + + So the refusal is stated here instead: the right-hand side must be a + string constant. ``os.path.join(os.sep, ...)``, ``sys.executable``, + ``__file__`` — every host-derived spelling is a ``Call``, a ``Name`` or an + ``Attribute``, and every one of them reds on Linux without a Windows + runner to find it. + """ + line = next(entry for entry in self.HOSTS[host].splitlines() if entry.startswith("ABSOLUTE = ")) + node = ast.parse(line).body[0].value + + assert isinstance(node, ast.Constant) and isinstance(node.value, str), ( + f"the {host} host COMPUTES its probe path rather than publishing one: " + f"{ast.unparse(node)}. Anything read off the running host — os.sep, " + "sys.executable, __file__, os.getcwd() — carries the RUNNER's dialect into " + "an emulation that is not the runner, which is exactly the round-8 defect; " + "a plain literal cannot" + ) + + def test_the_real_route_probe_deliberately_uses_the_host_dialect(self): + """The one exception, pinned so it is not "fixed" into a bug. + + ``REAL_ROUTE_PROBE`` exercises THIS interpreter's own ``pathlib`` with no + emulation anywhere, so the host's dialect is the correct one and + ``os.sep`` is the right way to build its path. Replacing it with a posix + literal would make the probe drive-relative on Windows and quietly change + what the 3.10 row measures. + """ + assert "os.sep" in self.REAL_ROUTE_PROBE, ( + "the real-route probe stopped building its path from os.sep. It has no " + "emulation installed, so a fixed literal would be the WRONG dialect on one " + "platform — this is the one probe that should read the host" + ) + assert "ABSOLUTE" not in self.REAL_ROUTE_PROBE, ( + "the real-route probe now reads a host constant's ABSOLUTE — it installs no " + "host, so that name is either undefined or leaking in from a composition" + ) + + def test_each_host_is_built_from_its_own_dialect_module(self): + """@memory's rule 2, pinned in the source because behaviour cannot pin it here. + + Building ``POSIX_HOST`` out of ``os.path`` is EQUIVALENT on this machine — + ``os.path IS posixpath``, so every verdict is identical and no probe can + tell. It is load-bearing on the Windows runner, where the same line would + capture ``ntpath.realpath`` and the "posix" host would emulate nt. That is + the round-7 defect exactly, one constant over. + + A mutant proved it: swapping the dialect module for ``os.path`` left all + 33 pins green. So the rule is asserted where it is checkable rather than + published as a survivor nobody can convict. + """ + assert "_posixpath_realpath = posixpath.realpath" in self.POSIX_HOST, ( + "the posix emulation no longer captures posixpath BY NAME. On nt os.path IS " + "ntpath, so this row would emulate the runner and report it as posix" + ) + assert "ntpath." in self.NT_HOST, "the nt emulation no longer references ntpath by name" + for name, host in (("POSIX_HOST", self.POSIX_HOST), ("NT_HOST", self.NT_HOST)): + # Comments are stripped first: both constants EXPLAIN the os.path trap + # in prose, and a check that cannot tell the explanation from the + # defect reds on its own documentation. (It did, on the first run.) + capture = "\n".join( + line for line in host.split("def ")[0].splitlines() if not line.lstrip().startswith("#") + ) + assert "os.path.realpath" not in capture, ( + f"{name} reads os.path before installing its own function — os.path IS the " + "host, so the emulation would inherit exactly what it exists to replace" + ) + + def test_the_capture_ordering_is_what_makes_the_cure_load_bearing(self): + """Why ``PATHLIB_FIRST`` exists, measured rather than asserted. + + Removing ``PATHLIB_FIRST`` is EQUIVALENT on 3.11+ and load-bearing on + 3.10 — a mutant confirmed the whole suite stays green without it here. The + claim it encodes is about ORDER, and order IS measurable on this host + through the published accessor: capture before the world and a bare module + patch cannot reach it; capture after, and the accessor takes the already + denied function and arms with no cure at all. + + That second row is also the scope @trigger corrected me on: import order + decides on 3.10, where a captured copy exists to go stale. On 3.12 the + flavour is read at call time and order is irrelevant — which is why this + pin measures the ORDERING against an emulated capture rather than claiming + anything about this interpreter's own pathlib. + """ + world = self._world_without_cure() + # The LIVE capture, deliberately: staleness is a property of holding a real + # copy, and a sentinel is stale-proof by construction — it returns its + # argument whenever it was captured. Measuring ordering through the + # sentinel shape reports NO in both directions and proves nothing, which + # is how the first version of this pin failed. Run under POSIX_HOST so the + # absolute probe convicts only when the patch landed. + live_capture = self.PUBLISHED_SHAPE.replace("staticmethod(_source_realpath)", "staticmethod(os.path.realpath)") + assert live_capture != self.PUBLISHED_SHAPE + + before = self._run(self.POSIX_HOST + live_capture + world + self.PROBE) + after = self._run(self.POSIX_HOST + world + live_capture + self.PROBE) + + assert "ACCESSOR_DIES: NO" in before, ( + "a capture taken BEFORE the world followed a later module patch — then there " + "is no stale copy, and the cure has nothing to cure: " + before + ) + assert "ACCESSOR_DIES: YES" in after, ( + "a capture taken AFTER the world did not pick up the denial, so ordering is " + "not the mechanism this class says it is: " + after + ) + assert "import pathlib" in self.PATHLIB_FIRST, ( + "PATHLIB_FIRST no longer imports pathlib ahead of the world, so on <=3.10 the " + "accessor would capture the already-denied function, the cure would look " + "unnecessary, and the row would lie in the direction of a passing test" + ) + + @pytest.mark.parametrize( + ("host", "absolute_verdict"), + [("posix", "ACCESSOR_DIES: NO"), ("nt", "ACCESSOR_DIES: YES")], + ) + def test_the_absolute_probe_only_discriminates_on_one_platform(self, host, absolute_verdict): + """Trap (c) and the round-7 red, as one two-row table over the REAL function. + + This is the only test here that deliberately captures the live + ``os.path.realpath``, because it is the only one making a claim ABOUT it. + Both rows are emulated; neither inherits the host. + + posix: a RELATIVE path convicts with the accessor untouched, an ABSOLUTE + one does not — which is why every other probe uses an absolute path. + + nt: BOTH convict. ``ntpath.realpath`` reads the cwd unconditionally, so + the absolute probe stops discriminating and a raise-shaped answer no + longer means the patch landed. That row is the round-7 CI red, kept as a + positive measurement instead of a fixed-and-forgotten symptom — it is the + whole reason the other probes capture a sentinel, and if it ever goes + green the sentinel has stopped being necessary and somebody should find + out why before deleting it. + """ + live_capture = self.SHAPE.replace("staticmethod(_source_realpath)", "staticmethod(os.path.realpath)") + assert live_capture != self.SHAPE + + relative = self.PROBE.replace("_accessor.realpath(ABSOLUTE)", '_accessor.realpath("./somewhere")') + + relative_out = self._run(self.HOSTS[host] + live_capture + self.BARE_MODULE_PATCH_ONLY + relative) + absolute_out = self._run(self.HOSTS[host] + live_capture + self.BARE_MODULE_PATCH_ONLY + self.PROBE) + + assert "ACCESSOR_DIES: YES" in relative_out, ( + f"under the {host} host a relative path did not convict a live capture — then " + "the absolute probe is not distinguishing what this class claims it does: " + relative_out + ) + assert absolute_verdict in absolute_out, ( + f"the {host} row moved: an absolute path against a LIVE captured realpath was " + f"expected to report {absolute_verdict!r}. On posix a change here means " + "posixpath started reading the cwd for absolute paths; on nt it means " + "ntpath.realpath stopped, and the sentinel below may no longer be needed: " + absolute_out + ) + + +class TestTheCallerIsNoneBranchSurvivesADeniedRealpath: + """The behavioural sibling of the AST ban, and the correction that produced it. + + The round-4 guidance said the deleted second ``inspect.stack()`` walk is + unreachable and only an AST ban can watch it. That was TOO STRONG, and + @spawn measured the correction (relayed by @devpulse 2026-08-31): the branch + is unreachable from IMPORT-shaped pins — ``apps/__init__.py`` always supplies + a real-file frame, the nine-branch reproduction stands — but it IS reachable + by calling ``_guard_branch_access()`` DIRECTLY from a ``python -c`` child. + Every frame there is a string pseudo-file or importlib, both skipped, so + ``_find_real_caller`` returns None and the branch RUNS. Under a realpath + denial a regrown walk dies in it; the cured plain ``return`` survives. + + Kept ALONGSIDE the AST ban rather than replacing it. The ban needs no + subprocess, names the offending line, and catches a reintroduction anywhere + in the tree; this one proves the cure in the world it was built for. A mutant + that regrows the walk kills both. + + TWO ARMING PROBES, because one is not enough here (@spawn's rules 2 and 4): + probe 1 proves the denial actually bites this interpreter, and probe 2 proves + ``_find_real_caller`` genuinely returned None — without it the child could be + exercising the same-branch allow instead, and the test would pass while + watching nothing. + + Runs as ``python -c``, never a script and never a heredoc (@commons, + @hooks): a script frame is a real on-disk file, ``getsourcefile`` + early-returns, and the denial is silently inert. + """ + + BODY = """ +import aipass.flow.apps.handlers as guard + +# Probe 2 BEFORE the assertion: the branch under test must actually be entered. +_ns = {"g": guard, "OUT": None} +exec(compile("OUT = g._find_real_caller()", "", "exec"), _ns) +print("CALLER_IS_NONE: " + ("YES" if _ns["OUT"] == (None, None) else "NO -> " + str(_ns["OUT"]))) + +try: + exec(compile("g._guard_branch_access()", "", "exec"), {"g": guard}) + print("GUARD: RETURNED") +except OSError as exc: + print("GUARD DIED: " + type(exc).__name__ + ": " + exc.__class__.__name__) +except ImportError: + print("GUARD: BLOCKED") +""" + + def test_calling_the_guard_directly_under_a_realpath_denial_returns(self): + result = _run_world(_WORLD_B, _STACK_CONTROL, self.BODY) + + assert result.returncode == 0, result.stdout + result.stderr + # Arming probe 1: the denial bites inspect.stack() on this interpreter. + assert "STACK_DIES: YES" in result.stdout, ( + "world B did not arm — a regrown walk would survive it and this test would pass.\n" + result.stdout + ) + # Arming probe 2: the branch under test is the one being exercised. + assert "CALLER_IS_NONE: YES" in result.stdout, ( + "_find_real_caller did not return None, so the guard took a different " + "path and this test is watching nothing.\n" + result.stdout + ) + assert "GUARD: RETURNED" in result.stdout, result.stdout + + +class TestNoModuleLevelLocationCallSurvives: + """The structural half, because behaviour cannot reach every reintroduction. + + Proven by @hooks (M7) and re-proved by @trigger, who restored the deleted + ``inspect.stack()`` walk and watched 1058 tests stay green: the walk sat in + ``_guard_branch_access``'s caller-is-None branch, and no import-shaped world + ever enters it — ``apps/__init__.py`` always supplies a real-file frame, so + ``_find_real_caller`` never returns None during an import. + + CORRECTED 2026-08-31 (@spawn, relayed by @devpulse): this docstring used to + end "a parse of the tree is the only instrument that sees it". That was TOO + STRONG. The branch is unreachable from IMPORT-shaped pins, which is not the + same as unreachable — calling the guard DIRECTLY from a ``python -c`` child + reaches it, and ``TestTheCallerIsNoneBranchSurvivesADeniedRealpath`` above is + that pin. Both are kept: regrowing the walk kills both, and this one needs no + subprocess and names the offending line anywhere in the tree. + + The ban is on the CALL — ``ast.Call`` whose func is ``inspect.stack`` — never + on the string, because this file and the cured modules SPELL the defect in + their docstrings to explain it, and a string ban would convict the + explanation along with the thing. + """ + + BANNED_ATTRS = frozenset({"resolve", "cwd", "getcwd", "realpath", "abspath"}) + + # The two functions in this tree that read the process cwd ON PURPOSE, and + # are RIGHT to. Both answer "where did the caller stand" — a location, + # observed — and both are reached at call time, never at import. Named here + # so the ban above can never delete a correct answer, which is the failure + # mode a blanket rule has and a measured one does not. + DELIBERATE_CALLER_CWD_READS = frozenset( + { + ("project_scope.py", "caller_cwd"), + ("resolve_location.py", "_get_caller_cwd"), + } + ) + + @staticmethod + def _apps_sources() -> list[Path]: + import aipass.flow.apps as flow_apps + + root = Path(flow_apps.__file__).parent + return [p for p in sorted(root.rglob("*.py")) if "__pycache__" not in p.parts and ".archive" not in p.parts] + + @staticmethod + def _module_level_location_calls(tree: ast.Module) -> list[tuple[int, str]]: + """Calls that infer a location and are evaluated when the module loads.""" + found = [] + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + for sub in ast.walk(node): + if not isinstance(sub, ast.Call): + continue + func = sub.func + attr = func.attr if isinstance(func, ast.Attribute) else None + if attr in TestNoModuleLevelLocationCallSurvives.BANNED_ATTRS: + found.append((sub.lineno, ast.unparse(sub))) + return found + + @staticmethod + def _inspect_stack_calls(tree: ast.Module) -> list[int]: + """``inspect.stack()`` calls at ANY depth — the unreachable-branch case.""" + found = [] + for sub in ast.walk(tree): + if not isinstance(sub, ast.Call): + continue + func = sub.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "stack" + and isinstance(func.value, ast.Name) + and func.value.id == "inspect" + ): + found.append(sub.lineno) + return found + + @staticmethod + def _cwd_reads_outside_the_named_two(source: Path, tree: ast.Module) -> list[str]: + """``Path.cwd()``/``os.getcwd()`` calls in functions nobody blessed.""" + offenders = [] + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if (source.name, node.name) in TestNoModuleLevelLocationCallSurvives.DELIBERATE_CALLER_CWD_READS: + continue + for sub in ast.walk(node): + if not isinstance(sub, ast.Call) or not isinstance(sub.func, ast.Attribute): + continue + if sub.func.attr in ("cwd", "getcwd"): + offenders.append(f"{source.name}:{sub.lineno} {node.name}() -> {ast.unparse(sub)}") + return offenders + + def test_no_module_level_resolve_or_cwd_read_anywhere_in_apps(self): + offenders = [] + for source in self._apps_sources(): + tree = ast.parse(source.read_text(encoding="utf-8")) + for lineno, text in self._module_level_location_calls(tree): + offenders.append(f"{source.name}:{lineno} {text}") + + assert offenders == [], ( + "module-level location calls crash the import on a Windows box with no cwd; " + "route them through handlers/repo_root.module_file(): " + ", ".join(offenders) + ) + + def test_no_inspect_stack_call_survives_in_apps(self): + offenders = [] + for source in self._apps_sources(): + tree = ast.parse(source.read_text(encoding="utf-8")) + for lineno in self._inspect_stack_calls(tree): + offenders.append(f"{source.name}:{lineno}") + + assert offenders == [], ( + "inspect.stack() reaches getmodule's unguarded os.path.realpath; " + "walk sys._getframe over f_code.co_filename instead: " + ", ".join(offenders) + ) + + def test_the_only_cwd_reads_left_are_the_two_that_mean_it(self): + """Seven ``_find_repo_root`` copies ended ``return Path.cwd()``. None may return. + + This is the QUIET half of the defect, and the half a ``try``/``except`` + would have left in place: cwd is a guess, four of those callers are + writers, and a writer with a guessed root writes into a tree nobody + chose. + """ + offenders = [] + for source in self._apps_sources(): + tree = ast.parse(source.read_text(encoding="utf-8")) + offenders.extend(self._cwd_reads_outside_the_named_two(source, tree)) + + assert offenders == [], ( + "a cwd read outside the two deliberate caller-cwd functions — " + "use handlers/repo_root.find_repo_root(): " + ", ".join(offenders) + ) + + # -- negative controls, both directions ------------------------------- + # + # A checker that convicts nothing and a checker that convicts everything + # look identical in a green summary. Every detector is run against source it + # MUST flag and source it must NOT. + + def test_the_module_level_detector_convicts_a_module_level_resolve(self): + tree = ast.parse("from pathlib import Path\nROOT = Path(__file__).resolve().parents[3]\n") + assert self._module_level_location_calls(tree), "the detector is blind to the exact line it exists to ban" + + def test_the_module_level_detector_clears_the_same_call_inside_a_function(self): + tree = ast.parse("from pathlib import Path\ndef f():\n return Path(__file__).resolve()\n") + assert self._module_level_location_calls(tree) == [], ( + "a resolve() inside a function is reached at CALL time, not import time — " + "convicting it would ban the guarded helper this cure is built on" + ) + + def test_the_stack_detector_convicts_a_call_in_an_unreachable_branch(self): + """@hooks' M7 and @trigger's 1058-green mutant, reproduced.""" + tree = ast.parse( + "import inspect\n" + "def guard(caller):\n" + " if caller is None:\n" + " for frame in inspect.stack():\n" + " pass\n" + " return\n" + " return\n" + ) + assert self._inspect_stack_calls(tree), "the reintroduction @hooks measured would land unnoticed" + + def test_the_stack_detector_clears_a_docstring_that_names_the_defect(self): + """The reason this is an AST ban and not a grep.""" + tree = ast.parse('"""Walks sys._getframe rather than inspect.stack() — see the cure."""\n') + assert self._inspect_stack_calls(tree) == [], ( + "a string ban convicts the explanation along with the defect, which is how a cure ends up undocumented" + ) + + def test_the_stack_detector_clears_an_unrelated_stack_attribute(self): + tree = ast.parse("import numpy\nx = numpy.stack([1, 2])\n") + assert self._inspect_stack_calls(tree) == [], "the ban is on inspect.stack, not on the word stack" + + def test_the_cwd_detector_convicts_a_fallback_in_an_unblessed_function(self): + source = Path("close_helpers.py") + tree = ast.parse("from pathlib import Path\ndef _find_repo_root():\n return Path.cwd()\n") + assert self._cwd_reads_outside_the_named_two(source, tree), ( + "the detector is blind to the exact seven-copy fallback it exists to ban" + ) + + def test_the_cwd_detector_clears_the_two_functions_that_mean_it(self): + source = Path("project_scope.py") + tree = ast.parse("from pathlib import Path\ndef caller_cwd():\n return Path.cwd()\n") + assert self._cwd_reads_outside_the_named_two(source, tree) == [], ( + "the ban deleted a correct answer — caller_cwd is ASKING where the caller stood" + ) + + def test_the_cwd_exemption_is_keyed_on_the_file_too(self): + """The same function name in a different file is not the blessed one.""" + source = Path("close_helpers.py") + tree = ast.parse("from pathlib import Path\ndef caller_cwd():\n return Path.cwd()\n") + assert self._cwd_reads_outside_the_named_two(source, tree), ( + "the exemption is name-only — any file could adopt the name and inherit the pass" + ) diff --git a/src/aipass/flow/tests/test_project_scope.py b/src/aipass/flow/tests/test_project_scope.py index 141d610c7..783e66b45 100644 --- a/src/aipass/flow/tests/test_project_scope.py +++ b/src/aipass/flow/tests/test_project_scope.py @@ -271,3 +271,81 @@ def test_no_project_is_said_in_words_not_left_as_none(self, tmp_path): from aipass.flow.apps.handlers.plan.project_scope import describe_project assert describe_project(None) == "no project" + + +from aipass.flow.apps.handlers.plan import project_scope # noqa: E402 + + +class TestTheRegisterGlobIsNotCaseFolded: + """The round-3 case-fold species, in the two flow lanes that carry it. + + ``pathlib``'s glob is case-INSENSITIVE on Windows and default macOS, so + ``*_REGISTRY.json`` also matches ``*_registry.json`` there — and flow's own + ``flow_json/`` holds eight lowercase plan registries. Flow was not in + round 3's nine-branch sweep, so both sites below were still uncured. + + MEASURED before fixing, on the live tree 2026-08-31: 237 files match the + folded pattern and are not exact-case, and **0 of 237** carry a top-level + ``branches`` key. So this was reachable-not-armed — the second clause of + the two-clause discriminator is what was holding, not the glob. That is a + property of today's disk, not of the code, which is why it is pinned rather + than left to luck: a register-shaped file arriving under a lowercase name + would make a subdirectory read as its own project and drop its rows out of + the real project's scope. + + These run on every OS. The bait is emulated by widening the LISTING, which + is exactly what a folding filesystem does — so the pin is red on Linux + against the uncured code rather than only on the platform that finds it. + """ + + def _project_dir(self, tmp_path: Path) -> Path: + root = tmp_path / "proj" + root.mkdir() + (root / "AIPASS_REGISTRY.json").write_text(json.dumps({"branches": [{"name": "flow"}]}), encoding="utf-8") + return root + + def test_a_lowercase_register_shaped_decoy_does_not_make_a_directory_a_project(self, tmp_path, monkeypatch): + """The harm, if a decoy were ever armed: a subdirectory becomes a project.""" + sub = tmp_path / "proj" / "sub" + self._project_dir(tmp_path) + sub.mkdir() + # The decoy: register-SHAPED, but named in lowercase. + (sub / "other_registry.json").write_text(json.dumps({"branches": [{"name": "nobody"}]}), encoding="utf-8") + + real_glob = Path.glob + + def folded_glob(self, pattern): + # What Windows/macOS actually do: the pattern matches case-blind. + if pattern == project_scope.REGISTER_GLOB: + return iter(sorted(p for p in real_glob(self, "*.json") if p.name.lower().endswith("_registry.json"))) + return real_glob(self, pattern) + + monkeypatch.setattr(Path, "glob", folded_glob) + + assert project_scope._holds_register(sub) is False, ( + "a lowercase *_registry.json was accepted as a project register — " + "on Windows this makes any directory holding one its own project root" + ) + + def test_the_exact_case_register_is_still_found(self, tmp_path): + """Negative control: the narrowing must not refuse the real thing.""" + root = self._project_dir(tmp_path) + assert project_scope._holds_register(root) is True + + def test_the_emulation_actually_widens_the_listing(self, tmp_path, monkeypatch): + """Control for the control: a folded glob that matches nothing proves nothing.""" + sub = tmp_path / "sub" + sub.mkdir() + (sub / "other_registry.json").write_text("{}", encoding="utf-8") + + real_glob = Path.glob + + def folded_glob(self, pattern): + if pattern == project_scope.REGISTER_GLOB: + return iter(sorted(p for p in real_glob(self, "*.json") if p.name.lower().endswith("_registry.json"))) + return real_glob(self, pattern) + + monkeypatch.setattr(Path, "glob", folded_glob) + assert list(sub.glob(project_scope.REGISTER_GLOB)) == [sub / "other_registry.json"], ( + "the bait is not reaching the code under test — every assertion above would be vacuous" + ) diff --git a/src/aipass/flow/tests/test_push_central.py b/src/aipass/flow/tests/test_push_central.py index e4c9d743c..0e51ccd0c 100644 --- a/src/aipass/flow/tests/test_push_central.py +++ b/src/aipass/flow/tests/test_push_central.py @@ -9,6 +9,7 @@ """Tests for push_central handler -- push Flow plan data to PLANS.central.json.""" import json +from pathlib import Path from unittest.mock import patch _MOD = "aipass.flow.apps.handlers.dashboard.push_central" @@ -26,44 +27,151 @@ def _import_mod(): class TestFindRepoRoot: - """Tests for _find_repo_root.""" + """``push_central._find_repo_root`` — now a delegation, not a private walk. + + REWRITTEN 2026-08-31. Flow carried SEVEN near-identical private + ``_find_repo_root`` copies, each ending ``return Path.cwd()``, and six were + called at MODULE level. These tests patched ``push_central.__file__`` to + steer that private walk; the walk lives in ``handlers/repo_root`` now, so + the patch no longer reaches it and the cases had to move with the code. + + ``test_falls_back_to_cwd_when_no_marker`` is REVERSED below rather than + deleted. It asserted ``result == Path.cwd()`` and was GREEN — a test + standing guard over the exact construct that takes CI down on a box whose + working directory is gone, and that silently resolves a WRITER against + whatever directory the caller's shell was pointing at when no registry + exists. @memory found the same reversed test in their own tree the same + week. The contract it pinned was wrong; the test now pins the correct one + and says why, so nobody restores the old assertion thinking they found a + regression. + """ + + def test_delegates_to_the_one_implementation(self): + """The private copy is gone: this must BE the shared answer.""" + mod = _import_mod() + from aipass.flow.apps.handlers import repo_root + + assert mod._find_repo_root() == repo_root.find_repo_root() def test_returns_dir_containing_registry(self, tmp_path): """Returns the directory containing AIPASS_REGISTRY.json.""" - mod = _import_mod() + from aipass.flow.apps.handlers.repo_root import find_repo_root + marker = tmp_path / "AIPASS_REGISTRY.json" marker.write_text("{}", encoding="utf-8") child = tmp_path / "a" / "b" / "c" child.mkdir(parents=True) - with patch(f"{_MOD}.__file__", str(child / "push_central.py")): - result = mod._find_repo_root() - assert result == tmp_path - - def test_falls_back_to_cwd_when_no_marker(self, tmp_path): - """Returns Path.cwd() when no AIPASS_REGISTRY.json is found.""" - mod = _import_mod() - sub = tmp_path / "sub" - sub.mkdir(exist_ok=True) - - with patch(f"{_MOD}.__file__", str(sub / "push_central.py")): - with patch("pathlib.Path.cwd", return_value=tmp_path): - result = mod._find_repo_root() - # No AIPASS_REGISTRY.json in any parent, so cwd is returned - assert result == tmp_path + assert find_repo_root(child) == tmp_path def test_finds_marker_in_immediate_parent(self, tmp_path): """Finds AIPASS_REGISTRY.json in the immediate parent directory.""" - mod = _import_mod() + from aipass.flow.apps.handlers.repo_root import find_repo_root + parent_dir = tmp_path / "parent" parent_dir.mkdir() (parent_dir / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") child_dir = parent_dir / "child" child_dir.mkdir() - with patch(f"{_MOD}.__file__", str(child_dir / "push_central.py")): - result = mod._find_repo_root() - assert result == parent_dir + assert find_repo_root(child_dir) == parent_dir + + def test_never_falls_back_to_cwd_when_no_marker(self, tmp_path, monkeypatch): + """THE REVERSED TEST. No marker anywhere → the SOURCE tree, never cwd. + + The old assertion was ``result == Path.cwd()``. Both defects it blessed + are pinned here: the process directory is not consulted at all (so a + deleted cwd cannot kill a module-level caller), and the answer does not + move when the caller's shell moves (so a writer cannot be steered into a + tree nobody chose). + """ + from aipass.flow.apps.handlers import repo_root + + sub = tmp_path / "sub" + sub.mkdir() + + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.setattr(Path, "cwd", staticmethod(lambda: elsewhere)) + + result = repo_root.find_repo_root(sub) + + assert result == repo_root.SOURCE_ROOT + assert result != elsewhere, "the walk is still answering with the process directory" + + def test_the_cwd_stand_in_is_live(self, tmp_path, monkeypatch): + """Control: a patch that never bites would make the test above vacuous.""" + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.setattr(Path, "cwd", staticmethod(lambda: elsewhere)) + + assert Path.cwd() == elsewhere + + +class TestTheBareWorldIsStatedNotInherited: + """Both marker worlds are asserted here, so neither is inherited from the host. + + ``AIPASS_REGISTRY.json`` is gitignored and machine-local. Every test above + ran on a machine that HAS it, and that is exactly how + ``test_success_returns_true`` shipped green and reddened all four Python + versions on CI (round 5, @devpulse): with no marker the walk takes the + fallback, the fallback LOGS, and the autouse ``mock_json_handler`` counted + two calls where the test pinned one. The production code was right the whole + time; the assertion was measuring the host. + + The count assertions are fixed in ``tests/conftest.py`` — the six + module-level callers are pre-imported, so the import-time diagnostic can + never land in a test window. These two tests are the other half: they say + out loud what the bare world DOES, so it is a pinned behaviour rather than a + condition nobody on a dev box ever sees. + """ + + def test_no_marker_means_source_root_and_a_logged_fallback(self, monkeypatch): + """Marker absent → SOURCE_ROOT, said out loud, never the process cwd.""" + from aipass.flow.apps.handlers import repo_root + + monkeypatch.setattr(repo_root, "exists_exactly", lambda path: False) + logged = [] + monkeypatch.setattr( + repo_root, + "_record_fallback", + lambda caller, marker, current: logged.append((caller, marker)), + ) + + result = repo_root.find_repo_root(caller="push_central") + + assert result == repo_root.SOURCE_ROOT + assert logged == [("push_central", repo_root.CORE_REGISTRY)], ( + "the fallback did not announce itself — a fallback nobody can see is how the next one survives" + ) + + def test_the_marker_denial_is_live(self, tmp_path, monkeypatch): + """Control: a denial that never bites makes the test above vacuous.""" + from aipass.flow.apps.handlers import repo_root + + marker = tmp_path / repo_root.CORE_REGISTRY + marker.write_text("{}", encoding="utf-8") + assert repo_root.find_repo_root(tmp_path) == tmp_path + + monkeypatch.setattr(repo_root, "exists_exactly", lambda path: False) + assert repo_root.find_repo_root(tmp_path) == repo_root.SOURCE_ROOT + + def test_the_fallback_records_without_ever_raising(self, monkeypatch): + """Six callers reach _record_fallback at IMPORT time. + + A diagnostic write that fails in a bare world must not become the import + crash the module exists to prevent, so the real recorder is exercised + here with its json_handler dead. + """ + from aipass.flow.apps.handlers import repo_root + + def explode(*args, **kwargs): + raise OSError("no writable tree") + + monkeypatch.setattr("aipass.flow.apps.handlers.json.json_handler.log_operation", explode) + + # Must not raise. + repo_root._record_fallback("push_central", repo_root.CORE_REGISTRY, Path("/nowhere")) # ============================================= diff --git a/src/aipass/hooks/.aipass/aipass_local_prompt.md b/src/aipass/hooks/.aipass/aipass_local_prompt.md index c68d0d5e6..e78122e56 100644 --- a/src/aipass/hooks/.aipass/aipass_local_prompt.md +++ b/src/aipass/hooks/.aipass/aipass_local_prompt.md @@ -76,13 +76,14 @@ apps/ stop_sound.py # Sound on session stop tool_sound.py # Sound on tool use telegram_response.py # Telegram reply delivery on Stop + module_root.py # module_file() -- the ONE import-time-safe __file__ resolve (dead-cwd cure) config/ # NOTE: under handlers/, not apps/ -- apps/config/ is an empty package loader.py # hooks.json discovery + validation, config-independent trust checks trust_registry.py # Trusted-project registry (enroll/revoke/hash checks) diagnostics.py # JSONL diagnostics config logs/ engine.jsonl # JSONL diagnostics -- 2 generations @ ~500KB = ~11 MINUTES of retention -tests/ # 50 test files, 1711 tests +tests/ # 52 test files, 1798 tests ``` ## Handler Categories @@ -127,6 +128,8 @@ EVERY reply that adds/renames/moves a handler MUST state either "provider settin ## Known Gotchas +- Never write a bare `Path(__file__).resolve()` at module scope -- on Windows `ntpath.realpath` reads cwd unconditionally, so it is an import-time cwd dependency. Use `handlers/module_root.module_file()`. The guard in `handlers/__init__.py` runs on EVERY hooks import, so a defect there masks every other site: cure it first, then re-measure. + - Exit code 2 has dual meaning: intentional block (with JSON) vs crash (without JSON). Engine distinguishes by checking stdout. - JSONL log lives at `logs/engine.jsonl` -- not in prax. Prax gets a copy via system_logger, but JSONL is the source of truth for hook diagnostics. - Provider settings carry multiple named bridge entries per event for UserPromptSubmit and PreCompact -- deliberate (per-handler output + timeout). New handlers on those events need their own provider entry. diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index e9c840f77..7dffdefe1 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -512,7 +512,7 @@ }, { "standard": "test_quality", - "reason": "Hooks branch does not use json_handler — has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture." + "reason": "Hooks branch does not use json_handler — has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture. | RETIREMENT-BOUND (@seedgo fcb16f01, 2026-08-29): test_quality is scheduled for retirement under DPLAN-0320/0321. Verified STILL LIVE and still scoring on 2026-08-30, so the rule is load-bearing today and stays. DELETE IT in the same window as the retirement merge — @seedgo mails the fleet when the date is set. Written into the rule itself because a rule that stops suppressing anything is unreadable from the outside." }, { "file": "tests/conftest.py", @@ -1318,6 +1318,16 @@ "file": "apps/modules/cc_transcripts.py", "standard": "modules", "reason": "Direct file ops on ~/.claude/projects/*.jsonl — external CC-native transcript files, not branch data. The module IS the CC transcript reader service; json_handler is for branch-managed JSON, not external platform files. Mirrors the cc_sessions.py ruling." + }, + { + "file": "apps/modules/bash_writes.py", + "standard": "json_structure", + "reason": "Pure string parser for edit_gate's scripted lane — reads a Bash command and returns resolved paths. Touches no JSON file at all, so there is nothing for json_handler to open and nothing to log_operation about. Same profile as grounding_content.py, which is bypassed here for the same reason." + }, + { + "file": "apps/modules/bash_writes.py", + "standard": "modules", + "reason": "Not a drone command surface: it is the write-target reader consumed by handlers/security/edit_gate.py inside a PreToolUse hook, where there is no CLI caller to route. Same pattern as grounding_content.py (consumed by 5 handlers) and cadence.py. print_introspection() IS provided, so the module still answers for itself." } ], "notes": { diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index eef781e84..0766eb4b8 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -94,6 +94,7 @@ src/aipass/hooks/ │ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off) │ │ ├── hookstatus.py # Config viewer (drone @hooks status) │ │ ├── alert_dismiss.py # Dismiss alerts (drone @hooks dismiss ) +│ │ ├── bash_writes.py # Write targets a shell command names — edit_gate's scripted lane │ │ ├── presence.py # Branch presence — claim/release/refresh for .ai_central/PRESENCE.central.json │ │ ├── sandbox.py # Kernel sandbox — srt/bwrap wrapper + per-role policy generator │ │ └── wire_verify.py # Wire verification — provider ↔ project hook wiring checker @@ -140,11 +141,12 @@ src/aipass/hooks/ │ │ └── diagnostics.py # JSONL logging for hook execution │ ├── handlers/cli/ # CLI utilities (not hooks — no handle()) │ │ └── help_flags.py # Help-flag detection — did the caller ask, or instruct? -│ └── handlers/json/ # JSON utilities (not hooks — no handle()) -│ └── json_handler.py # Auto-creating JSON handler for hooks data files +│ ├── handlers/json/ # JSON utilities (not hooks — no handle()) +│ │ └── json_handler.py # Auto-creating JSON handler for hooks data files +│ └── handlers/module_root.py # Guarded __file__ resolution — the one import-time-safe spelling ├── logs/ │ └── engine.jsonl # JSONL diagnostics (every hook execution) -└── tests/ # 1711 tests across 50 test files (1709 pass, 2 env-skipped) +└── tests/ # 1798 tests across 52 test files (1796 pass, 2 env-skipped) ``` ## How It Works @@ -157,6 +159,42 @@ src/aipass/hooks/ 6. Exit code 2 without JSON = crash (log error, continue to next hook) 7. All hook stdout concatenated and returned to platform +## Importing Without a Working Directory + +Every hooks module must import in a process whose working directory is gone. +`ntpath.realpath` reads `os.getcwd()` UNCONDITIONALLY — not only for relative +paths, the way `posixpath` does — and `Path.resolve()` routes through it. So on +Windows every `Path(__file__).resolve()` *reached at import time* is an +import-time working-directory dependency, and a process whose cwd was deleted +cannot import the module at all. (Measured on the Windows CI gate 2026-08-31, +@memory's finding, routed here by @devpulse.) + +**The one spelling:** `apps/handlers/module_root.py` → `module_file(__file__)`. +It still attempts `.resolve()` — normalising symlinks is why the call exists — +and falls back to the absolute spelling only in the world where the alternative +is a dead import. New module-level `__file__` resolution goes through it. + +**The count was wrong until the guard was cured.** `apps/handlers/__init__.py` +runs a cross-branch import guard on *every* hooks import, and it died first, so +all 68 modules reported that one line and no other site was visible. Curing the +guard is what made the remaining sites measurable — the honest sequence was +1 → 5 → 6, each cure unmasking the next. `tests/test_import_dead_cwd.py` pins +the whole tree, discovered by walk rather than listed. + +**Two worlds, because one proves half.** World A emulates ntpath (`realpath` +reads cwd, `getcwd` denied) and convicts an unguarded `resolve()`; on Linux it +does NOT convict `inspect.stack()`, because there the raise happens inside +`getabsfile()` where inspect catches it. World B denies `os.path.realpath` +outright and convicts `inspect.stack()` at `inspect.py:1009`. The guard now +walks frames with `sys._getframe` and reads the import line with `linecache`. + +The `inspect.stack()` ban is **structural** (AST, not grep): the guard's +caller-is-None branch is unreachable from any import probe, so a regrown call +there is invisible to the world tests — a mutant proved exactly that, killed +only by the AST pin. AST and not a string ban because this guard's own +docstrings name the defect, and a spelling ban convicts prose while acquitting +code. + ## Two Log Streams Hook execution is recorded twice, at different levels of detail: @@ -185,7 +223,7 @@ Every handler is verified wired in `hooks.json` (31 entries, 30 distinct names). | Event | Hooks | Description | |---|---|---| | UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, compass_recall, feedback_pulse, context_gauge, temporal, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + governance recall + feedback + context gauge + temporal + auto-process + TG mirror | -| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound | +| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound. `edit_gate` reads Bash too (scripted cross-project lane) once its matcher is widened — see the CONFIG WIRE note under Edit Gate | | PostToolUse | auto_fix, auto_watchdog, post_compact_regrounding | Diagnostics + watchdog + post-compaction re-ground backstop | | SubagentStop | subagent_gate | Seedgo validation | | Stop | stop_sound, telegram_response, presence_release | Bell + Telegram delivery + presence release | @@ -256,6 +294,134 @@ Trust runs downward. Downward writes also have to stay open because the host tre Where no project root is resolvable on either side, the gate allows the write: a fence that cannot locate a boundary must not invent one. +### The admin exemption — one seat reaches outwards + +Patrick's ruling, 2026-08-30 (compassed as @devpulse entry 322): the cross-project fence stays for +every agent, and **@devpulse is the sole exemption** — *"It is only you who can reach outwards. +Nobody else."* + +The exemption is granted on a **verified** identity, never a claimed one. `_is_admin_seat()` consumes +@ai_mail's `is_verified_admin_caller()` — the same boolean their projects sweep gates on, which +delegates to @devpulse's `admin_grant` reference implementation and its 5-leg contract (caller, +registry-resolved cert, cert content, HMAC-SHA256 signature, registry admin flag). No second +implementation lives here, and `ADMIN_SEAT = "devpulse"` decides nothing — it appears in the log line +only. A session standing in a directory named `devpulse` with no valid grant on the machine is +refused. + +**What a hook has to supply.** That rail reads identity from the env drone's router stamps +(`AIPASS_CALLER_BRANCH` / `AIPASS_CALLER_CWD`), and a PreToolUse hook is not drone-invoked — measured +2026-08-30: a hook process carries `AIPASS_BRANCH_NAME` and `AIPASS_SESSION_TYPE`, and neither caller +variable. Left alone the rail answers "unprovable" for every seat and the exemption never opens. So +the gate stamps `AIPASS_CALLER_CWD` from the platform's own record of the session directory — the +same species of evidence drone stamps, from the same kind of source — and lets the rail do the rest. +An existing stamp is never overwritten, and the stamp does not outlive the check. + +**Residual, stated rather than discovered:** leg 1 resolves through the session directory, so a +session whose cwd is devpulse's tree *and* a validly signed grant on this machine together satisfy +it. That is the grant's own stated threat model (`admin_grant.py`, "Security note": every agent here +shares one OS user; the signature buys tamper-evidence, not attack-proofing). It is also no new +reach — a session standing in devpulse's tree already writes that tree under the cross-branch fence, +which keys on the same cwd. + +The exemption is narrow: it opens the **cross-project** fence only. Inbox writes, the cross-branch +fence, daemon confinement and the `.trinity` caps are unchanged for every seat including the admin. + +### The scripted lane — writes made through Bash + +Until 2026-08-30 every fence above was invisible to a write made through the shell, because the gate +matched only Edit/Write/MultiEdit/NotebookEdit. @devpulse measured it live: their `Edit` into a +sibling project was correctly refused and `sed -i` on the same file went straight through — for all +18 citizens, not just the admin seat. + +`apps/modules/bash_writes.py` reads a Bash command and reports the paths it can be **seen** to write; +`edit_gate` then applies the identical direction rules and prints the identical refusal. Two reading +modes: + +| Mode | Commands | What is reported | +|---|---|---| +| Directed verbs | `>`, `>>`, `tee`, `sed -i`, `cp`, `mv`, `ln`, `install`, `rsync`, `dd of=`, `touch`, `mkdir`, `truncate` | The target the verb's own grammar names — so `cat /other/x > ./mine` names `./mine`, and reading a foreign file stays legal | +| Interpreters | `python`, `python3`, `node`, `perl`, `ruby`, `php`, `sh`, `bash`, `zsh`, `awk` — inline script or heredoc | **Every** path the command holds, because arbitrary code has no grammar naming its target | + +`cd` inside a chain moves the ground the next segment stands on, so `cd ../Other && sed -i s/a/b/ f.json` +is resolved against `../Other`. + +**What it deliberately does NOT catch.** A perfect shell parser is not the bar and is not achievable; +the residual is published as data in `bash_writes.NOT_CAUGHT` and printed by `drone @hooks` module +introspection, so this list and the code cannot drift apart: + +- paths built from shell or program variables (`$DIR/x`) — there is nothing to resolve +- paths reached through a symlink pointing into another project +- `find -exec` / `xargs`, which name the write verb but not the operand +- background or detached writes (`nohup`, `disown`, `at`, `cron`, `systemd-run`) +- metadata-only changes: `chmod`, `chown`, `touch -t` on an existing file +- `git`, `gh`, `drone`, `aipass` — they name no write verb this parser reads; their own fences apply +- writes made by a process the command merely starts (a server, a test runner) +- a path spelled for the *other* operating system's filesystem — `C:\Proj\x` read on Linux names no + drive that exists here, so it resolves relative and reads as local + +A command the parser cannot read at all (an unbalanced quote, an internal error) **allows** and logs: +a parser that has learned nothing about a command must not convict on it. + +**Both separator spellings are read (2026-08-31).** `shlex` runs in POSIX mode, where a backslash is +an *escape* — so it ate every separator of a Windows path and +`C:\Users\me\Vera-Studio\f.json` arrived as the single token `C:UsersmeVera-Studiof.json`. That is +not a degraded reading, it is the dangerous one: a drive-absolute foreign path became one relative +filename, resolved under the caller's **own** project, and read as a local write. Every catch +category in the table returned exit 0 on Windows, and the class had never been green there since the +day it shipped. Found by @devpulse in `windows-test.yml` (which runs the whole tree, unlike main CI); +reproduced on Linux at the parser level, because the bug never needed a Windows runner — only a +backslash. + +The fix does not pick a dialect. A command containing a backslash is lexed **twice** — once with +shlex's escape rules (correct for POSIX `cp a\ b.txt dest`) and once with backslashes protected +(correct for a Windows path) — and the write targets are unioned. Reading `\` as a separator only +ever *adds* path components, so a local write can never become foreign by it, while the reverse is +exactly how a foreign write became local. Separators are then normalised before `pathlib` sees the +token, on every OS: `WindowsPath("C:/a/b")` is absolute and correct, so one spelling reaches `Path` +from both dialects and the parser's reading stops depending on which machine runs it. + +What is *not* portable is the **root**, which is why the drive-letter entry is in the residual list +above rather than left to be discovered. The tests pin the Windows spelling in-process on any OS by +back-slashing a real local path (`str(p).replace("/", "\\")`) — a no-op on Windows, and on Linux the +exact spelling that killed the parser, still resolving to the same real file under the same real +fence. + +> **CONFIG WIRE — landed 2026-08-30, the lane is live.** `pre_edit_gate` now carries +> `matcher: "Bash|Edit|MultiEdit|Write|NotebookEdit"` in `.aipass/hooks.json` (the matcher `git_gate` +> and `registry_gate` already had). Before that widening, Bash events never reached the handler at +> all and the scripted lane was dark no matter what the code did. Because any byte change to +> `hooks.json` invalidates the trust hash in `~/.aipass/trusted_projects.json`, the edit was followed +> immediately by `aipass trust /home/patrick/Projects/AIPass` — an un-re-enrolled config edit takes +> *every* hook dark. Verify with `drone @hooks hookstatus`; re-run `aipass trust` after any further +> edit to that file. + +### `.trinity` caps — a write is judged on what it AUTHORS + +`edit_gate` also measures `.trinity/local.json` and `observations.json` against @memory's published +caps (`memory.config.json` → `entry_limits`, read through their `entry_limits` module — this gate +never restates a cap). An entry over its character limit is refused, and so is an entry whose +canonical field is *missing*: a renamed `learning` where the config says `value` leaves the extractor +with no key to read, and `""` and "cannot read this" are different answers. + +**Both refusals apply only to entries the write authored.** An entry byte-identical to the one +already on disk is *carried*, not authored — reported at INFO with a pointer to `drone @memory lint`, +never blocked. This was narrowed to `todos` on 2026-08-27 and made universal again on 2026-08-30 +after @memory measured what the narrowing did: their rollover lane failed identically every 20 +minutes for three hours, because the extractor removed a tail, wrote the *smaller* document back, and +this gate refused the whole file over an entry in the head the extraction never touched. The archiver +is always on the losing side of that trade — the file cannot get smaller because it is too big. + +The other half of the evidence is this gate's own refusal text: writes made through Bash reach the +scripted lane's *project* fence, not the cap check, so a write gate is structurally blind to how +drift ARRIVES and was never the thing that could detect it. Detecting drift already on disk is +`drone @memory lint`'s job, which reads the file. + +Identity is the raw entry, never its index. A prepend shifts every position down, so an index-keyed +diff would call the whole file newly authored on exactly the write that authored nothing. + +Carrying a drifted entry does not license adding another in the same shape: a NEW entry with a +missing canonical field is authored, and refused. + ### The diagnostics block After an edit leaves type errors behind, `auto_fix` records them in `.diagnostics_state.json` and `edit_gate` stops you editing *other* files in that branch until they are fixed. Two rules keep that block honest (both reported by @seedgo with a live repro, 2026-08-13): @@ -345,7 +511,7 @@ restated. - All branches via hook dispatch — every Claude Code session routes through the engine - @ai_mail dispatch_monitor — `build_policy` + `build_srt_config` + `resolve_bwrap_command` at the agent launch boundary -*Last Updated: 2026-08-25* +*Last Updated: 2026-08-30* --- diff --git a/src/aipass/hooks/apps/handlers/__init__.py b/src/aipass/hooks/apps/handlers/__init__.py index fdf840d93..36d4283fc 100644 --- a/src/aipass/hooks/apps/handlers/__init__.py +++ b/src/aipass/hooks/apps/handlers/__init__.py @@ -1,6 +1,7 @@ """HOOKS handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "aipass.hooks" @@ -11,24 +12,60 @@ def _find_real_caller(): Skips this file, importlib internals, and frozen modules. Returns tuple: (file_path, import_line) or (None, None). - """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) - - for frame_info in stack: - filename = frame_info.filename - if this_file in str(Path(filename).resolve()): + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on the + Windows CI gate 2026-08-31: inspect.stack() needs a READABLE CWD, and it + needs one before any of this function's own code runs. It builds a FrameInfo + per frame, which calls getsourcefile() -> getmodule() -> os.path.realpath(); + ntpath.realpath calls os.getcwd() unconditionally on its first lines, before + it even checks whether the path is absolute, and that call site in getmodule + is not inside a try. On POSIX the equivalent raise happens earlier, inside + getabsfile(), where inspect catches it - which is why this was invisible on + Linux for as long as it existed. A frame's co_filename is already a string + in memory; reading it touches nothing. + + That getmodule path is reached only for a frame whose filename is not on + disk, and every import carries frames - so + this guard, which runs on EVERY hooks import, was the one that fired. + """ + # Path.resolve() reaches the same ntpath.realpath, so this is guarded too — + # __file__ is already absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back continue - if filename.startswith("<") or "importlib" in filename: + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename + + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue + # linecache is what inspect used for code_context; called directly it + # reads one named file and returns "" rather than raising when it cannot. import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -52,10 +89,11 @@ def _guard_branch_access(): caller_file, import_line = _find_real_caller() if caller_file is None: - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return + # No caller outside this file: an interactive session, a -c script, or an + # importlib-only stack. All three are allowed. This used to walk + # inspect.stack() again looking for / and then return + # either way — a second copy of the cwd dependency above, in service of a + # branch that could not change the answer. return branch_path = "/" + MY_BRANCH.replace(".", "/") + "/" diff --git a/src/aipass/hooks/apps/handlers/config/diagnostics.py b/src/aipass/hooks/apps/handlers/config/diagnostics.py index 201e898ab..39b2f2ade 100644 --- a/src/aipass/hooks/apps/handlers/config/diagnostics.py +++ b/src/aipass/hooks/apps/handlers/config/diagnostics.py @@ -16,8 +16,9 @@ from aipass.prax import append_jsonl from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.hooks.apps.handlers.module_root import module_file -BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent.parent +BRANCH_ROOT = module_file(__file__).parent.parent.parent.parent _PROD_LOG_FILE = BRANCH_ROOT / "logs" / "engine.jsonl" diff --git a/src/aipass/hooks/apps/handlers/json/json_handler.py b/src/aipass/hooks/apps/handlers/json/json_handler.py index 194707425..fa1ff0209 100644 --- a/src/aipass/hooks/apps/handlers/json/json_handler.py +++ b/src/aipass/hooks/apps/handlers/json/json_handler.py @@ -19,6 +19,7 @@ import inspect from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.hooks.apps.handlers.module_root import module_file if sys.platform == "win32": os.environ.setdefault("PYTHONUTF8", "1") @@ -27,7 +28,7 @@ if _reconfigure is not None: _reconfigure(encoding="utf-8", errors="replace") -_BRANCH_ROOT = Path(__file__).resolve().parents[3] +_BRANCH_ROOT = module_file(__file__).parents[3] _BRANCH_NAME = _BRANCH_ROOT.name JSON_DIR = _BRANCH_ROOT / f"{_BRANCH_NAME}_json" diff --git a/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py b/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py index 95eb40f2f..baa361252 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py @@ -1,15 +1,16 @@ # =================== AIPass ==================== # Name: auto_fix.py -# Version: 1.1.0 +# Version: 1.2.0 # Description: Post-edit diagnostics — syntax, lint, type, pattern, seedgo checks (PostToolUse) # Branch: hooks # Layer: apps/handlers/lifecycle # Created: 2026-05-22 -# Modified: 2026-08-27 +# Modified: 2026-08-30 # ============================================= """Runs diagnostics on edited files and surfaces errors for the agent to fix.""" +import importlib import json import os import re @@ -228,6 +229,73 @@ def _run_pyright_check(file_path: str) -> list[dict]: return [] +_CHECKLIST_MARKER_FALLBACK = "[FAIL]" + + +def _checklist_marker() -> str: + """The token @seedgo's checklist prints on a finding — read, never restated. + + This consumer grepped for a cross for months after checklist stopped + printing one. @seedgo changed the marker to plain ASCII for a good reason + (@spawn scripted against the cross, got zero hits across all 18 files of a + branch, and nearly deleted 41 bypass rules on that "proof") and the reader + was not moved with it. The failure was silent by construction: an empty list + reads exactly like a clean file, so the gate reported compliance while + catching nothing. + + Reading the constant off the owner's module is the fix for the SPECIES, not + just this instance — the next marker change cannot silently disagree with + this file. When seedgo is not importable at all, fall back and SAY SO: a + quiet fallback is how the first one lasted. + """ + try: + checklist = importlib.import_module("aipass.seedgo.apps.modules.checklist") + except Exception as exc: + logger.warning( + "[HOOKS] auto_fix: seedgo checklist module unreadable (%s) — using fallback marker %r", + exc, + _CHECKLIST_MARKER_FALLBACK, + ) + return _CHECKLIST_MARKER_FALLBACK + marker = getattr(checklist, "FINDING_MARKER", "") + if isinstance(marker, str) and marker: + return marker + logger.warning( + "[HOOKS] auto_fix: seedgo publishes no FINDING_MARKER — using fallback %r", + _CHECKLIST_MARKER_FALLBACK, + ) + return _CHECKLIST_MARKER_FALLBACK + + +def _parse_checklist_findings(stdout: str, marker: str) -> list[str]: + """Pull findings out of checklist output, rejoining Rich's wrapped lines. + + The output is laid out for a human at a fixed console width, so a long + detail continues on the next line WITHOUT the marker. Reading one line per + finding truncated the detail mid-sentence — the standard's name survived and + the reason for the finding did not. + """ + findings: list[str] = [] + current: str | None = None + for raw in stdout.split("\n"): + line = raw.strip() + if line.startswith(marker): + if current: + findings.append(current) + current = line[len(marker) :].strip().lstrip("—-").strip() + continue + if current is None: + continue + if not line or line.startswith("✓") or line.startswith("All "): + findings.append(current) + current = None + continue + current = f"{current} {line}" + if current: + findings.append(current) + return [f for f in findings if f] + + def _run_seedgo_checklist(file_path: str) -> list[str]: if "/.claude/hooks/" in file_path: return [] @@ -242,16 +310,20 @@ def _run_seedgo_checklist(file_path: str) -> list[str]: timeout=15, cwd=aipass_home, ) + # The exit code carries nothing: checklist never calls sys.exit on a + # standards failure, so it returns 0 with eight findings on stdout + # (measured 2026-08-30). Discarding output on a non-zero code would + # therefore throw away the ONLY signal there is the moment seedgo ever + # does start exiting non-zero — the same drift that produced the marker + # bug, pre-armed. Parse the text either way; report a non-zero code + # rather than act on it. if result.returncode != 0: - return [] - violations: list[str] = [] - for line in result.stdout.split("\n"): - line = line.strip() - if line.startswith("✗"): - violation = line[1:].strip() - if violation: - violations.append(violation) - return violations[:5] + logger.info( + "[HOOKS] auto_fix: seedgo checklist exited %d for %s (parsing stdout anyway)", + result.returncode, + Path(file_path).name, + ) + return _parse_checklist_findings(result.stdout, _checklist_marker())[:5] except FileNotFoundError: logger.info("[HOOKS] auto_fix: drone not found for seedgo checklist") except Exception as exc: diff --git a/src/aipass/hooks/apps/handlers/module_root.py b/src/aipass/hooks/apps/handlers/module_root.py new file mode 100644 index 000000000..90dff70b4 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/module_root.py @@ -0,0 +1,88 @@ +# =================== AIPass ==================== +# Name: module_root.py +# Version: 1.0.0 +# Description: Resolve a module's __file__ without an import-time cwd read +# Branch: hooks +# Layer: apps/handlers +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""One guarded spelling for module-level __file__ resolution. + +ntpath.realpath reads os.getcwd() UNCONDITIONALLY - not only for relative +paths, the way posixpath does - and Path.resolve() routes through it +(measured on the Windows CI gate 2026-08-31, @memory's finding, routed here +by @devpulse). So on Windows every module-level Path(__file__).resolve() is +an import-time working-directory dependency: a process whose cwd was deleted +cannot import the module. + +Hooks carried FIVE such sites, and they were invisible until the guard in +apps/handlers/__init__.py was cured: that guard runs on every hooks import +and died first, so every module reported the same line and none of these +five could be seen. Curing the loudest site is what MADE the rest +measurable - the count before the guard cure was one, and it was wrong. + +Shape mirrors @memory's repo_root.module_file - the ratified fleet cure - +sized to this branch, as @devpulse's module_root already is: hooks has no +module-level repo-root walk, so only the resolve guard lives here. +""" + +from pathlib import Path + +from aipass.prax.apps.modules.logger import get_system_logger + +logger = get_system_logger() + +MODULE_NAME = "module_root" + + +def module_file(file: str) -> Path: + """A module's own path, absolute, resolved when the filesystem allows. + + .resolve() is still attempted, because normalising symlinks is why the + call is there and it succeeds on every healthy machine. The fallback is + only reached in the world where the alternative is a dead import, and it + is sound: __file__ has been absolute since Python 3.9, so the return is + the right file either way - just spelled through the symlink rather than + past it. + + Args: + file: A module's __file__. + + Returns: + The module's path, resolved if the filesystem could be asked. + """ + path = Path(file) + try: + return path.resolve() + except OSError as exc: + # debug, not warning: in the world that reaches this line every + # module import takes it, and five identical warnings describe one + # condition — reported once, loudly, by whichever lane fails on it. + logger.debug(f"[{MODULE_NAME}] Cannot resolve {path} ({type(exc).__name__}) — using its absolute spelling") + _record_unresolved(path, exc) + return path + + +def _record_unresolved(path: Path, exc: OSError) -> None: + """Record the fallback without ever raising. + + Runs at module import time on all five callers; a diagnostic write that + fails in a bare world must not become the import crash this module + exists to prevent. + + Args: + path: The __file__ that could not be resolved. + exc: The OSError resolve() raised. + """ + try: + from aipass.hooks.apps.handlers.json import json_handler + + json_handler.log_operation( + "module_file_unresolved", + {"path": str(path), "error": f"{type(exc).__name__}: {exc}"}, + module_name=MODULE_NAME, + ) + except Exception as inner: # noqa: BLE001 - an audit line must never take an import down + logger.debug(f"[{MODULE_NAME}] fallback not recorded: {type(inner).__name__}: {inner}") diff --git a/src/aipass/hooks/apps/handlers/notification/telegram_response.py b/src/aipass/hooks/apps/handlers/notification/telegram_response.py index 4b0355399..912eb0d7e 100644 --- a/src/aipass/hooks/apps/handlers/notification/telegram_response.py +++ b/src/aipass/hooks/apps/handlers/notification/telegram_response.py @@ -32,12 +32,13 @@ from aipass.prax import append_jsonl from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.hooks.apps.handlers.module_root import module_file PENDING_DIR = Path.home() / ".aipass" / "telegram_pending" MIRROR_DIR = Path.home() / ".aipass" / "telegram_bots" PENDING_TTL = 3600 TELEGRAM_CHAR_LIMIT = 4096 -_PROD_DELIVERY_LOG = Path(__file__).resolve().parent.parent.parent.parent / "logs" / "telegram_delivery.jsonl" +_PROD_DELIVERY_LOG = module_file(__file__).parent.parent.parent.parent / "logs" / "telegram_delivery.jsonl" def _get_delivery_log() -> Path: diff --git a/src/aipass/hooks/apps/handlers/security/edit_gate.py b/src/aipass/hooks/apps/handlers/security/edit_gate.py index 403df1e28..2c45ba008 100644 --- a/src/aipass/hooks/apps/handlers/security/edit_gate.py +++ b/src/aipass/hooks/apps/handlers/security/edit_gate.py @@ -1,11 +1,11 @@ # =================== AIPass ==================== # Name: edit_gate.py -# Version: 1.6.0 -# Description: Cross-project, cross-branch and inbox write protection (PreToolUse) +# Version: 1.8.0 +# Description: Cross-project (tool + scripted), cross-branch and inbox write protection (PreToolUse) # Branch: hooks # Layer: apps/handlers/security # Created: 2026-05-21 -# Modified: 2026-08-27 +# Modified: 2026-08-30 # ============================================= """Blocks unsafe edits: inbox writes, cross-project and cross-branch writes, daemon confinement, diagnostics state.""" @@ -21,6 +21,12 @@ EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} TRUSTED_CROSS_WRITERS: tuple[str, ...] = ("devpulse", "seedgo", "spawn") +# The one seat that reaches outwards. Patrick, 2026-08-30, compassed as devpulse +# entry 322: "It is only you who can reach outwards. Nobody else." The cross- +# project fence stays for every other agent, tool lane and scripted lane alike. +# Named here for the log line only — WHO is decided by the verified rail below, +# never by this string matching a directory. +ADMIN_SEAT = "devpulse" # A project root is the directory holding a *_REGISTRY.json — the same marker # @ai_mail's find_project_root uses (handlers/paths.py). Deliberately identical: # the file fence and the mail fence must draw the boundary in the same place, or @@ -70,6 +76,62 @@ def _find_project_root(start: Path) -> Path | None: return None +def _is_admin_seat(cwd: str) -> bool: + """True only when the 5-leg admin grant verifies for this session. + + Consumes @ai_mail's ``is_verified_admin_caller`` — the same boolean their + projects sweep gates on — rather than mirroring it. The contract has one + home (@devpulse's ``admin_grant``, FPLAN-0401) and a second reading of it + here could silently disagree with the lane that already enforces it. + + THE ONE THING A HOOK MUST SUPPLY. That rail reads identity from the env + drone's router stamps (``AIPASS_CALLER_BRANCH`` / ``AIPASS_CALLER_CWD``), + and a PreToolUse hook is not drone-invoked: neither variable exists in the + hook process, so the rail would answer "unprovable" for devpulse and every + other seat alike and the exemption would never open. What the hook does + have is the platform's own record of the session directory, handed to it in + the hook payload — the same species of evidence drone stamps, from the same + kind of source: the process that launched the session, not the agent + running inside it. So the caller cwd is stamped here and the rail does the + rest: the passport walk, the registry-resolved certificate, the HMAC, the + admin flag. An existing stamp is never overwritten — a drone-invoked caller + keeps the identity drone gave it. + + Deliberately NOT a name check. ``ADMIN_SEAT`` never decides anything: a + session standing in a directory called devpulse with no valid grant on the + machine is refused, which is the defect ``drone rm`` fell to and the reason + the dispatch named it. + + Residual, stated rather than discovered: leg 1 resolves through the session + directory, so a session whose cwd is devpulse's tree AND a validly signed + grant on this machine together satisfy it. That is the grant's own stated + threat model — every agent here shares one OS user, and the signature buys + tamper-EVIDENCE, not attack-proofing (admin_grant.py, "Security note"). It + is also no new reach: a session standing in devpulse's tree already writes + devpulse's tree under the cross-branch fence, which keys on the same cwd. + + Fails closed at every edge: an unimportable rail, a raise, or an unprovable + caller all return False. + """ + try: + vc = importlib.import_module("aipass.ai_mail.apps.handlers.users.verified_caller") + except Exception as exc: + logger.warning("[HOOKS] edit_gate: admin lane dark — verified-caller rail unavailable: %s", exc) + return False + + stamped = not os.environ.get("AIPASS_CALLER_CWD") and bool(cwd) + if stamped: + os.environ["AIPASS_CALLER_CWD"] = cwd + try: + return bool(vc.is_verified_admin_caller()) + except Exception as exc: + logger.warning("[HOOKS] edit_gate: admin verification raised (refusing): %s", exc) + return False + finally: + if stamped: + os.environ.pop("AIPASS_CALLER_CWD", None) + + def _check_project_boundary(cwd: str, target: Path) -> dict | None: """Block a write that crosses out of the caller's project. @@ -92,10 +154,17 @@ def _check_project_boundary(cwd: str, target: Path) -> dict | None: caller_root = _find_project_root(Path(cwd)) if caller_root is None: return None - target_root = _find_project_root(target.parent) - if target_root is None or target_root == caller_root: + target_root = _crossing_root(caller_root, target) + if target_root is None: return None - if caller_root in target_root.parents: + if _is_admin_seat(cwd): + logger.info( + "[HOOKS] edit_gate: cross-project write ALLOWED for the admin seat (@%s): %s -> %s (%s)", + ADMIN_SEAT, + caller_root.name, + target_root.name, + target, + ) return None logger.warning( @@ -104,9 +173,33 @@ def _check_project_boundary(cwd: str, target: Path) -> dict | None: target_root, target, ) + return _cross_project_block(caller_root, target_root, target, "") + + +def _crossing_root(caller_root: Path, target: Path) -> Path | None: + """Return the foreign project root *target* lands in, or None if it stays home. + + The direction rules live here alone so the tool lane and the scripted lane + cannot answer the same question differently — which is exactly how the + scripted lane came to be open while the tool lane was fenced. + + The walk starts AT *target*, not at its parent: a bash operand can name a + directory (``mkdir``, ``cp -r``), and starting at the parent would read a + write to a project root as a write to the tree that contains it. + """ + target_root = _find_project_root(target) + if target_root is None or target_root == caller_root: + return None + if caller_root in target_root.parents: + return None + return target_root + + +def _cross_project_block(caller_root: Path, target_root: Path, target: Path, how: str) -> dict: + """Build the refusal both lanes print. *how* names the shell verb, or "" for a tool edit.""" + lane = f"Cross-project write blocked ({how})" if how else "Cross-project write blocked" reason = ( - f"Cross-project write blocked: project '{caller_root.name}' cannot write into " - f"project '{target_root.name}'.\n" + f"{lane}: project '{caller_root.name}' cannot write into project '{target_root.name}'.\n" f"Target: {target}\n" "A project writes inside itself only — never into its host or a sibling. This is the " "file-layer twin of the mail fence that refuses cross-project sends.\n" @@ -119,6 +212,61 @@ def _check_project_boundary(cwd: str, target: Path) -> dict | None: } +def _check_bash_project_boundary(cwd: str, command: str) -> dict | None: + """Block a cross-project write made through the shell rather than a tool. + + The tool lane was fenced and this one was not: @devpulse's Edit into a + sibling project was refused on 2026-08-30 and ``sed -i`` on the same file + went through, for every seat, not just the admin one. Same boundary, same + direction rules, same refusal text — only the evidence differs, because a + shell command names its targets in grammar rather than in a ``file_path`` + field. + + Scope is honest by construction: ``bash_writes`` reports only what it can + SEE, and what it cannot see is enumerated in ``bash_writes.NOT_CAUGHT`` and + repeated in the README. A parser that guessed would refuse correct commands, + which is the failure mode that teaches agents to route around a gate. + + Returns a block dict, or None to allow. + """ + if not command: + return None + caller_root = _find_project_root(Path(cwd)) + if caller_root is None: + return None + try: + bw = importlib.import_module("aipass.hooks.apps.modules.bash_writes") + targets = bw.write_targets(command, cwd) + except Exception as exc: + # A parser that cannot read a command has learned nothing about it. It + # must not convict on that, and it must not go quiet about it either. + logger.warning("[HOOKS] edit_gate: bash write-target scan failed (allowing): %s", exc) + return None + + for target, how in targets: + target_root = _crossing_root(caller_root, target) + if target_root is None: + continue + if _is_admin_seat(cwd): + logger.info( + "[HOOKS] edit_gate: scripted cross-project write ALLOWED for the admin seat (@%s): %s -> %s (%s)", + ADMIN_SEAT, + caller_root.name, + target_root.name, + target, + ) + return None + logger.warning( + "[HOOKS] edit_gate: scripted cross-project write refused: %s -> %s via %s (%s)", + caller_root, + target_root, + how, + target, + ) + return _cross_project_block(caller_root, target_root, target, how) + return None + + def _get_package_from_cwd(cwd: str) -> str: parts = Path(cwd).parts for i, part in enumerate(parts): @@ -172,37 +320,29 @@ def _entries_of(container: Any, kind: str) -> list[tuple[str, Any]]: return [] -_RESHAPE_ONLY_FALLBACK: tuple[str, ...] = ("todos",) - - -def _reshape_only_sections(el: Any) -> tuple[str, ...]: - """The containers where on-disk drift may legitimately persist. - - Read off @memory's already-imported ``entry_limits`` at call time rather - than restated here. Two lists of "containers we may not prune" would - disagree within a release, and this gate and @memory's push must exempt the - same set or one of them is wrong about the other. - - This is not a new dependency — ``_check_trinity_change`` already imports the - module for ``load_entry_limits`` and ``changed_entries``; this reads an - attribute off the object it already holds. +def _log_carried(entry_type: str, container: str, key: str, field: str) -> None: + """Record a drifted entry this write carried but did not author. - On an @memory too old to publish the constant, fall back to todos and SAY - SO. The alternative default — exempting nothing — would refuse every write - to a file carrying one drifted todo, bricking the branch the exemption - exists to protect. + Carried debt must not be SILENT — that was the half of my 2026-08-27 + concern that was right, and @memory kept it when they reversed the rest: + their writer logs a CARRIED line too. Not refused, not hidden, and not + printed at the agent either: detection belongs to ``drone @memory lint``, + which reads whole files on demand, rather than to a write gate that sees + only the next write. INFO, because carrying inherited drift is not + misbehaviour and a standing condition logged as a warning is what fed + @trigger's escalation lane the last time. """ - sections = getattr(el, "RESHAPE_ONLY_SECTIONS", None) - if isinstance(sections, (tuple, list)): - return tuple(sections) - logger.warning( - "[HOOKS] edit_gate: entry_limits publishes no RESHAPE_ONLY_SECTIONS — falling back to %s", - _RESHAPE_ONLY_FALLBACK, + logger.info( + "[HOOKS] edit_gate: CARRIED (not authored, not refused) %s [%s] in %s — no '%s' field. " + "Cure it with drone @memory lint.", + entry_type, + key, + container, + field, ) - return _RESHAPE_ONLY_FALLBACK -def _missing_field_violations(before: dict, after: dict, limits: dict, el: Any) -> list[dict]: +def _missing_field_violations(before: dict, after: dict, limits: dict) -> list[dict]: """Refuse entries whose CANONICAL field is absent (DPLAN-0318 bug B3). The cap check reads one field name per entry type, taken from @memory's @@ -214,20 +354,31 @@ def _missing_field_violations(before: dict, after: dict, limits: dict, el: Any) compliance. ``""`` and "cannot read this" are different answers; a field the gate cannot find is named, not measured. - The on-disk exemption is now TODOS ONLY, matching @memory's narrowing. It - existed so a drifted fleet would not be bricked mid-migration; the fleet - converged, so everywhere else it protects nothing real and hides everything - new — a drifted entry written straight to disk would read as "already - there" on the next write and never surface again. - - todos keep it because no machine may prune them: the push is forbidden to - archive open work, so only the branch's own agent can cure a drifted todo, - and refusing every write until it does would brick the rollover that - preserves everything else. Even there the exemption covers only what is - ALREADY ON DISK, by raw-entry identity — carrying one drifted todo must not - license adding ten more in the same shape. + THE ON-DISK RULE IS NOW UNIVERSAL, not todos-only (2026-08-30, @memory's + entry_limits 1.6.0). A write is refused for what it AUTHORS, never for what + it CARRIES. I narrowed this to todos on 08-27 because the fleet had + converged and "unchanged and over cap passes" looked like it hid new drift + rather than protecting old. Both halves of that were wrong about the world, + and @memory proved it from the outside: their rollover lane failed + identically every 20 minutes for three hours because the extractor removed a + tail, wrote the SMALLER document back, and a write gate refused the whole + file over an entry in the head the extraction never touched. The archiver is + always on the losing side of that trade — the file cannot get smaller + because it is too big. My own refusal text is the other half of the + evidence: writes made through Bash are not checked, so a write gate is + structurally blind to how drift ARRIVES and cannot be the thing that + detects it. That job belongs to ``drone @memory lint``, which reads the file. + + So this checker was fixed to match. @memory fixed their half and mine still + deadlocked — measured live before this change: a write with an identical + before and after still drew a refusal here. Identity is the raw entry, never + the index: a prepend shifts every position down, and an index-keyed diff + would call the whole file newly authored on exactly the write that authored + nothing. + + Carrying a drifted entry still does not license adding another in the same + shape — a NEW entry with a missing canonical field is authored, and refused. """ - exempt_sections = _reshape_only_sections(el) hits: list[dict] = [] for type_name, type_def in limits.get("entry_types", {}).items(): if not isinstance(type_def, dict): @@ -240,16 +391,19 @@ def _missing_field_violations(before: dict, after: dict, limits: dict, el: Any) if after_container is None: continue - exempt = container_key in exempt_sections - legacy = [entry for _, entry in _entries_of(before.get(container_key), kind)] if exempt else [] + carried = [entry for _, entry in _entries_of(before.get(container_key), kind)] for key, entry in _entries_of(after_container, kind): # Plain-string entries carry their own text — measurable, and # @memory's extractor already handles them. if not isinstance(entry, dict) or field in entry: continue - if exempt and entry in legacy: - continue # Already on disk in a container nothing may prune + if entry in carried: + # Byte-identical to disk: this write did not author it. Report, + # never refuse — refusing here is what deadlocks the rollover + # that is trying to shrink the very file being complained about. + _log_carried(type_name, container_key, key, field) + continue hits.append( { "entry_type": type_name, @@ -340,19 +494,25 @@ def _dedupe_violations(violations: list[dict]) -> list[dict]: def _evaluate_limits(before: dict, after: dict, limits: dict, el: Any) -> dict | None: """Diff changed entries and return block dict or None (allow).""" over = el.changed_entries(before, after, limits) - over = _dedupe_violations(over + _missing_field_violations(before, after, limits, el)) + over = _dedupe_violations(over + _missing_field_violations(before, after, limits)) if not over: return None if limits.get("enforce"): lines = ["Unwritable .trinity entries (fix before saving):"] for v in over: lines.append(_format_violation(v)) - # Say what this gate can actually see. It is a PreToolUse hook on Edit/Write, - # so a write made through Bash (python -c, heredoc, sed) never reaches it and - # is not checked. Three branches have drifted over cap through that lane — - # @baud to 2529/300 for a week, @api to 12 sessions + 16 learnings. Claiming + # Say what this gate can actually see. Bash now reaches this handler, but + # only its PROJECT fence — the cap check runs on the Edit/Write lane alone, + # so a write made through python -c, a heredoc or sed is still unmeasured. + # Three branches have drifted over cap through that lane — @baud to + # 2529/300 for a week, @api to 12 sessions + 16 learnings. Claiming # enforcement it does not have is what let the drift read as compliance. - lines.append(" (Edit/Write only — writes made through Bash are NOT checked. Caps are yours to keep there.)") + # Same reason the on-disk pass is universal: a gate blind to how drift + # ARRIVES cannot be the thing that refuses a file for already carrying it. + lines.append( + " (Caps are measured on Edit/Write only — a write made through Bash is not measured. " + "Cure drift already on disk with drone @memory lint.)" + ) return { "stdout": json.dumps({"decision": "block", "reason": "\n".join(lines)}), "exit_code": 2, @@ -629,6 +789,18 @@ def handle(hook_data: dict) -> dict: tool_input = hook_data.get("tool_input", {}) file_path = tool_input.get("file_path", "") + # The scripted lane. Only the project fence runs here — the branch, + # inbox, daemon and .trinity checks read a single named file, and a + # shell command has no such field to read. Claiming they apply would be + # the enforcement-it-does-not-have mistake the caps advisory already + # names out loud. + if tool_name == "Bash": + cwd = hook_data.get("cwd", "") or os.getcwd() + return _check_bash_project_boundary(cwd, tool_input.get("command", "")) or { + "stdout": "", + "exit_code": 0, + } + if tool_name not in EDIT_TOOLS: return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/modules/bash_writes.py b/src/aipass/hooks/apps/modules/bash_writes.py new file mode 100644 index 000000000..53097593a --- /dev/null +++ b/src/aipass/hooks/apps/modules/bash_writes.py @@ -0,0 +1,403 @@ +# =================== AIPass ==================== +# Name: bash_writes.py +# Version: 1.2.0 +# Description: Write targets a shell command can be seen to name (edit_gate's scripted lane) +# Branch: hooks +# Layer: apps/modules +# Created: 2026-08-30 +# Modified: 2026-08-31 +# ============================================= + +"""Reads a Bash command and reports which paths it can be seen to WRITE. + +Why this exists: `edit_gate` is a PreToolUse hook on Edit/Write/MultiEdit/ +NotebookEdit, so every fence it draws is invisible to a write made through the +shell. @devpulse measured the gap live on 2026-08-30 — their Edit into a sibling +project was correctly blocked, and `sed -i` on the same file went straight +through. The tool lane was fenced; the scripted lane was open to every seat. + +THE BAR, set in the dispatch and kept here: a perfect shell parser is not the +goal and is not achievable. Catching the obvious write-verbs aimed at a foreign +project root is. Everything this parser deliberately cannot see is listed in +:data:`NOT_CAUGHT` — a residual that is documented is a known gap; a residual +that is discovered is a defect. + +Two reading modes, because shell commands are two different things: + +- **Directed verbs** — redirection, `tee`, in-place `sed`, `cp`, `mv`, `dd of=`. + The write target is known from the verb's own grammar, so only the target is + reported and reading a foreign path stays legal (`cat /other/x > ./mine` + names ./mine, not /other/x). +- **Interpreters** — the set in :data:`_INTERPRETERS`, whether invoked with an + inline script or a heredoc. These run arbitrary code, so no grammar tells us + the target. Every path they are handed is reported. This deliberately catches + a read-only invocation that merely opens a foreign file: an interpreter + holding a foreign path cannot be distinguished from one writing to it, and + the caller is told exactly that. +""" + +import re +import shlex +from pathlib import Path + +from aipass.cli.apps.modules import err_console +from aipass.prax.apps.modules.logger import system_logger as logger + +CONSOLE = err_console + +# Shell operators that end one command and start the next. +_SEPARATORS = frozenset({"&&", "||", ";", "|", "&", "\n"}) + +# Every operand is a write target. +_ALL_OPERANDS = frozenset({"tee", "touch", "mkdir", "truncate"}) + +# The LAST operand is the destination; the ones before it are sources. +_LAST_OPERAND = frozenset({"cp", "mv", "ln", "install", "rsync"}) + +# Writes in place, but only with the in-place flag. Without it sed is a filter +# and names nothing — treating every `sed` operand as a target would refuse +# reading a foreign file through a pipe, which is not what the fence is for. +_INPLACE_FLAGS = frozenset({"-i", "--in-place"}) + +# Runs arbitrary code. No grammar names the target, so every path counts. +_INTERPRETERS = frozenset( + {"python", "python3", "node", "nodejs", "perl", "ruby", "php", "bash", "sh", "zsh", "awk", "gawk"} +) + +# NOTE ON TOOLING THAT CARRIES ITS OWN FENCE — `drone`, `aipass`, `git`, `gh`. +# This module first held an explicit skip-list for them. A mutation run killed +# it: removing the skip changed no result, because none of those commands is a +# verb this parser reads a target from, so the list suppressed nothing. A rule +# that has stopped suppressing anything is indistinguishable from a load-bearing +# one (@seedgo's bypass-rot species, mailed 2026-08-29), so it is gone and the +# fact is written here instead. Their fences stay theirs: `drone rm` refuses +# outside its own project and git/gh are git_gate's lane. A redirection they +# carry (`drone x > /foreign/f`) IS still caught — the shell does that write, +# not the tool. + +# Redirection tokens. `>&` is a descriptor dup (`2>&1`), never a filename. +_REDIRECT_OPS = frozenset({">", ">>", ">|"}) + +# A run of characters containing a separator — how a path looks inside +# interpreter source, where quoting has already been stripped or mangled by the +# lexer. BOTH separators count: a regex that only knew "/" returned nothing at +# all for a Windows-spelled path, so the interpreter mode was not merely +# degraded on Windows, it was blind (measured 2026-08-31). +_PATH_RUN = re.compile(r"[~\w.@+\-]*[/\\][~\w./\\@+\-]*") + +# The opening of a heredoc: << or <<-, an optionally quoted delimiter word. +_HEREDOC_OPEN = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_]\w*)\1") + +# Trailing syntax that rides along when a path is lifted out of source code. +_TRAILING_JUNK = ",;:)]}'\"`" + +# What this parser does NOT see. Stated as data so the reply, the README and the +# tests all quote the same list instead of three drifting prose copies. +NOT_CAUGHT: tuple[str, ...] = ( + "paths built from shell or program variables ($DIR/x, os.environ-derived) — nothing to resolve", + "paths reached through a symlink that points into another project", + "find -exec / xargs, which name the write verb but not the operand", + "background or detached writes (nohup, disown, at, cron, systemd-run)", + "metadata-only changes: chmod, chown, touch -t on an existing file", + "git, gh, drone and aipass — they name no write verb this parser reads; their own fences apply", + "writes made by a process the command merely starts (a server, a test runner)", + "a path spelled for the OTHER operating system's filesystem — 'C:\\Proj\\x' read on Linux " + "names no drive that exists here, so it resolves relative and reads as local. Separators are " + "understood on every OS; ROOTS are only walkable on the OS that has them.", +) + + +def print_introspection() -> None: + """Print module structure for drone routing. + + The residual is printed, not just stored: a gap you can read from a + terminal is one an agent can plan around. A gap that lives only in a + constant gets discovered instead. + """ + CONSOLE.print("[bold cyan]bash_writes[/bold cyan] — write targets a shell command can be seen to name") + CONSOLE.print("[dim]Consumed by handlers/security/edit_gate.py for the scripted cross-project lane.[/dim]") + CONSOLE.print() + CONSOLE.print("[yellow]NOT CAUGHT — the residual, stated rather than discovered:[/yellow]") + for gap in NOT_CAUGHT: + CONSOLE.print(f" - {gap}") + + +def _strip_heredoc_bodies(command: str) -> str: + """Blank out heredoc bodies before the command is read as shell syntax. + + A heredoc body is DATA, not shell. The lexer cannot know that, so a mail + body or a doc that merely QUOTES a shell command had its quoted text read as + real syntax — found live within minutes of shipping, when this gate blocked + the reply describing its own proof. That is the "gate that blocks its own + audit" pattern, and a fence nobody can write about is one people route + around rather than report. + + The lines are replaced with blanks rather than deleted so nothing shifts: + the opening line keeps its own tokens (the verb, its flags, any redirection + that really is shell), and only the body stops being syntax. + + This does NOT weaken the interpreter rule. ``_interpreter_targets`` scans the + ORIGINAL command text, so a heredoc handed to python still surrenders every + path it holds — that catch was the point of the interpreter mode and it is + pinned by its own test. + """ + if "<<" not in command: + return command + lines = command.split("\n") + out: list[str] = [] + pending: list[str] = [] + for line in lines: + if pending: + if line.strip() == pending[0]: + pending.pop(0) + out.append("") + continue + out.append("") + continue + out.append(line) + pending.extend(match.group(2) for match in _HEREDOC_OPEN.finditer(line)) + return "\n".join(out) + + +def _tokenize(command: str) -> list[str]: + """Split a command into tokens, keeping shell operators as their own tokens. + + ``punctuation_chars`` makes shlex emit ``&&``/``||``/``;``/``|``/``>`` as + tokens instead of gluing them to words, and posix mode strips quotes so a + quoted path arrives as a path. An unbalanced quote is a lexer error, not a + reason to give up: fall back to whitespace splitting and say so, because + silently returning no tokens would read exactly like a command with no + write targets. + """ + try: + lexer = shlex.shlex(command, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + return list(lexer) + except ValueError as exc: + logger.info("[HOOKS] bash_writes: lexer fell back to whitespace split (%s)", exc) + return command.split() + + +def _readings(command: str) -> list[list[str]]: + """Every token stream this command could honestly be. Usually one. + + THE BUG THIS EXISTS FOR (@devpulse, windows-test.yml, 2026-08-31): in POSIX + mode a backslash is an ESCAPE, so shlex ate every separator of a Windows + path — ``C:\\Users\\me\\Vera-Studio\\f.json`` arrived as the single token + ``C:UsersmeVera-Studiof.json``. That is not a degraded reading, it is the + dangerous one: a drive-absolute foreign path became one relative filename, + resolved under the CALLER'S OWN project, and read as a local write. Every + catch category the fleet closed on 08-30 returned exit 0 on Windows. The + class had never been green there. + + A fence must not have to guess which dialect it is reading. Both readings + are produced and their results are UNIONED, so neither spelling can be used + to slip past the other: + + - escape reading — shlex's own, correct for POSIX ``cp a\\ b.txt dest`` + - separator reading — backslashes doubled so the lexer emits them literally, + correct for a Windows path + + Where they agree (no backslash anywhere) there is one reading and no cost. + Where they disagree the union is strictly safer: reading ``\\`` as a + separator only ever adds path components, so a local write can never become + foreign by it — while the reverse, the escape reading, is exactly how a + foreign write became local on Windows. + """ + readings = [_tokenize(_strip_heredoc_bodies(command))] + if "\\" in command: + protected = _tokenize(_strip_heredoc_bodies(command).replace("\\", "\\\\")) + if protected != readings[0]: + readings.append(protected) + return readings + + +def _segments(tokens: list[str]) -> list[list[str]]: + """Group tokens into individual commands, split on shell separators.""" + out: list[list[str]] = [] + current: list[str] = [] + for token in tokens: + if token in _SEPARATORS: + if current: + out.append(current) + current = [] + continue + current.append(token) + if current: + out.append(current) + return out + + +def _looks_like_path(token: str) -> bool: + """True for operands that could name a file on disk. + + A token carrying an unexpanded variable is excluded, not guessed at: half a + path resolved against the wrong root would name a project nobody addressed. + """ + if not token or token.startswith("-"): + return False + if "$" in token or "*" in token or "?" in token: + return False + return "/" in token or "\\" in token or "." in token or token.isidentifier() + + +def _resolve(token: str, cwd: Path) -> Path | None: + """Resolve one operand against the segment's working directory.""" + token = token.strip().strip(_TRAILING_JUNK) + if not token: + return None + # Separators are normalised on EVERY OS, not just Windows. pathlib accepts + # "/" natively on Windows (WindowsPath("C:/a/b") is absolute and correct), + # so one spelling reaches Path from both dialects and the parser's reading + # of a command stops depending on which machine happens to run it. What + # does NOT become portable is the ROOT: a drive letter names nothing on + # Linux, so it resolves relative and reads as local — published in + # NOT_CAUGHT rather than left to be discovered. + token = token.replace("\\", "/") + try: + candidate = Path(token).expanduser() + return candidate if candidate.is_absolute() else (cwd / candidate) + except (OSError, ValueError, RuntimeError) as exc: + logger.info("[HOOKS] bash_writes: unresolvable operand %r: %s", token, exc) + return None + + +def _redirect_targets(segment: list[str], cwd: Path) -> list[tuple[Path, str]]: + """Collect `> file` / `>> file` destinations.""" + hits: list[tuple[Path, str]] = [] + for index, token in enumerate(segment): + if token not in _REDIRECT_OPS or index + 1 >= len(segment): + continue + target = _resolve(segment[index + 1], cwd) + if target is not None: + hits.append((target, f"redirection ({token})")) + return hits + + +def _operands(segment: list[str]) -> list[str]: + """Non-flag, non-operator tokens of a command, excluding the command name.""" + out: list[str] = [] + for token in segment[1:]: + if token in _REDIRECT_OPS or token.startswith(("-", ">", "<", "&")): + continue + out.append(token) + return out + + +def _verb_targets(segment: list[str], cwd: Path) -> list[tuple[Path, str]]: + """Collect write targets named by a known verb's own grammar.""" + verb = Path(segment[0]).name + operands = [t for t in _operands(segment) if _looks_like_path(t)] + hits: list[tuple[Path, str]] = [] + + if verb == "dd": + for token in segment[1:]: + if token.startswith("of="): + target = _resolve(token[3:], cwd) + if target is not None: + hits.append((target, "dd of=")) + return hits + + if verb == "sed": + if not any(flag in _INPLACE_FLAGS or flag.startswith("-i") for flag in segment[1:] if flag.startswith("-")): + return hits + # The first operand is the script when it was not given via -e/-f. + targets = operands if any(f.startswith(("-e", "-f")) for f in segment[1:]) else operands[1:] + return [(t, "sed -i") for t in (_resolve(o, cwd) for o in targets) if t is not None] + + if verb in _ALL_OPERANDS: + return [(t, verb) for t in (_resolve(o, cwd) for o in operands) if t is not None] + + if verb in _LAST_OPERAND and len(operands) >= 2: + target = _resolve(operands[-1], cwd) + if target is not None: + hits.append((target, f"{verb} destination")) + return hits + + +def _interpreter_targets(segment: list[str], raw: str, cwd: Path) -> list[tuple[Path, str]]: + """Collect every path an interpreter invocation is handed. + + The raw segment text is scanned, not just the tokens: a heredoc body reaches + the lexer as mangled words — an open-call arrives with its quotes stripped + and its arguments glued to the path — and lifting the + slash-bearing run out of the raw text is the only reading that survives it. + """ + verb = Path(segment[0]).name + if verb not in _INTERPRETERS: + return [] + seen: set[str] = set() + hits: list[tuple[Path, str]] = [] + for match in _PATH_RUN.findall(raw): + token = match.strip(_TRAILING_JUNK) + if not token or token in seen or "$" in token: + continue + # Separators alone are not a path. Widening the run to accept "\\" made + # every escaped quote inside a python -c body (\\") match as one, which + # would have put filesystem root in a refusal that named a real target + # elsewhere. Harmless to the verdict — root holds no registry — but a + # refusal listing paths the command never named is one nobody believes. + if not any(char.isalnum() for char in token): + continue + seen.add(token) + target = _resolve(token, cwd) + if target is not None: + hits.append((target, f"{verb} (interpreter — may write any path it holds)")) + return hits + + +def write_targets(command: str, cwd: str) -> list[tuple[Path, str]]: + """Return every (path, why) this command can be seen to write. + + Args: + command: The raw Bash command string from the tool input. + cwd: The session working directory relative paths resolve against. + + Returns: + A list of (resolved path, human-readable reason) pairs. Empty when the + command names no write target this parser can see — which is not the + same as "writes nothing"; see :data:`NOT_CAUGHT`. + """ + if not command or not command.strip(): + return [] + try: + base = Path(cwd) if cwd else Path.cwd() + except OSError as exc: + logger.info("[HOOKS] bash_writes: no usable cwd (%s)", exc) + return [] + + # Two texts, deliberately: heredoc bodies are stripped for the SYNTAX read + # (a quoted command in a mail body is not a command) and kept for the + # interpreter read (a heredoc handed to python really can write anything). + hits: list[tuple[Path, str]] = [] + seen: set[tuple[Path, str]] = set() + for tokens in _readings(command): + current = base + for segment in _segments(tokens): + if not segment: + continue + verb = Path(segment[0]).name + + # `cd` inside a chain moves the ground the next segment stands on. + # Not tracking it would let `cd ../Other && sed -i s/a/b/ f.json` + # resolve f.json against the caller's own project and read as a + # local write. + if verb == "cd" and len(segment) > 1: + moved = _resolve(segment[1], current) + if moved is not None: + current = moved + continue + + for hit in ( + *_redirect_targets(segment, current), + *_verb_targets(segment, current), + *_interpreter_targets(segment, command, current), + ): + # The two readings overlap heavily; a caller told the same + # thing twice would see a refusal that names one write as two. + if hit in seen: + continue + seen.add(hit) + hits.append(hit) + + return hits diff --git a/src/aipass/hooks/apps/modules/cadence.py b/src/aipass/hooks/apps/modules/cadence.py index caf4ee3c5..be3521025 100644 --- a/src/aipass/hooks/apps/modules/cadence.py +++ b/src/aipass/hooks/apps/modules/cadence.py @@ -23,6 +23,7 @@ from aipass.cli.apps.modules import err_console from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.hooks.apps.handlers.module_root import module_file try: import fcntl @@ -33,7 +34,7 @@ CONSOLE = err_console _GUARD_DIR = Path(tempfile.gettempdir()) -_BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent +_BRANCH_ROOT = module_file(__file__).parent.parent.parent _CONFIG_PATH = _BRANCH_ROOT / "hooks_json" / "custom_config" / "cadence_config.json" _DEBOUNCE_S = 2.0 diff --git a/src/aipass/hooks/apps/modules/engine.py b/src/aipass/hooks/apps/modules/engine.py index 629498648..cb0329aec 100644 --- a/src/aipass/hooks/apps/modules/engine.py +++ b/src/aipass/hooks/apps/modules/engine.py @@ -23,9 +23,10 @@ from aipass.cli.apps.modules import err_console from aipass.hooks.apps.handlers.cli.help_flags import wants_help from aipass.hooks.apps.handlers.config.diagnostics import log_entry as _log, tail_log +from aipass.hooks.apps.handlers.module_root import module_file CONSOLE = err_console -BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent +BRANCH_ROOT = module_file(__file__).parent.parent.parent HELP_COMMANDS = [ ("log", "Tail recent hook activity (last 20 entries)"), diff --git a/src/aipass/hooks/apps/modules/sandbox.py b/src/aipass/hooks/apps/modules/sandbox.py index 35d9ac1cc..ba33eaf46 100644 --- a/src/aipass/hooks/apps/modules/sandbox.py +++ b/src/aipass/hooks/apps/modules/sandbox.py @@ -25,10 +25,11 @@ from aipass.cli.apps.modules import err_console from aipass.hooks.apps.handlers.cli.help_flags import wants_help from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.hooks.apps.handlers.module_root import module_file CONSOLE = err_console -_MODULE_DIR = Path(__file__).resolve().parent +_MODULE_DIR = module_file(__file__).parent _SRT_RESOLVE = _MODULE_DIR / "_srt_resolve.mjs" _VAR_TMP = Path("/var/tmp") diff --git a/src/aipass/hooks/tests/test_auto_fix.py b/src/aipass/hooks/tests/test_auto_fix.py index ce9d58c90..6b72215da 100644 --- a/src/aipass/hooks/tests/test_auto_fix.py +++ b/src/aipass/hooks/tests/test_auto_fix.py @@ -1,10 +1,10 @@ # =================== AIPass ==================== # Name: test_auto_fix.py -# Version: 1.1.0 +# Version: 1.2.0 # Description: Tests for auto_fix lifecycle handler # Branch: hooks # Created: 2026-05-22 -# Modified: 2026-08-27 +# Modified: 2026-08-30 # ============================================= """Tests for handlers/lifecycle/auto_fix.py. @@ -20,6 +20,15 @@ from pathlib import Path from unittest.mock import MagicMock, patch +# Stand-ins for tests whose subprocess is mocked — nothing here is ever touched +# on disk. Built rather than written as POSIX literals: this file already +# carries 24 standing windows_compat/hardcoded_path findings from the older +# tests, and the 2026-08-30 marker fix is what finally made @seedgo's checklist +# reachable enough to say so. New tests do not add to a count that is about to +# be reported to its owner. +_FAKE_HOME = str(Path(tempfile.gettempdir()) / "fake_aipass_home") +_FAKE_PY = str(Path(tempfile.gettempdir()) / "check.py") + class TestAutoFixSkips: def test_skip_non_edit_tool(self): @@ -336,16 +345,103 @@ def test_run_pyright_skips_claude_hooks(self, mock_run): @patch("subprocess.run") def test_run_seedgo_checklist_returns_violations(self, mock_run): + """REVERSED 2026-08-30. This test pinned a marker nobody printed. + + It fed the parser a cross and asserted two findings came back, and it + passed for months while the live path returned [] on every file — the + fixture was the only place the cross still existed. @seedgo reported the + drift (checklist emits ``[FAIL]``); the output below is copied from a + real run against a violating file. + """ from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_seedgo_checklist mock_run.return_value = MagicMock( returncode=0, - stdout="✓ file_header: OK\n✗ missing encoding param\n✗ bad import\n", + stdout=( + "probe.py\n" + " ✓ cli\n" + " [FAIL] — debug_print: 1 bare print() call(s) on lines 5\n" + " ✓ imports\n" + " [FAIL] — hardcoded_path: 1 hardcoded path(s): L6: POSIX home path\n" + ), ) - with patch.dict("os.environ", {"AIPASS_HOME": "/home/user/Projects/AIPass"}): - violations = _run_seedgo_checklist("/tmp/check.py") + with patch.dict("os.environ", {"AIPASS_HOME": _FAKE_HOME}): + violations = _run_seedgo_checklist(_FAKE_PY) assert len(violations) == 2 - assert "missing encoding param" in violations[0] + assert "debug_print" in violations[0] + assert "hardcoded_path" in violations[1] + + @patch("subprocess.run") + def test_the_dead_cross_marker_finds_nothing(self, mock_run): + """The old fixture, run against the fixed reader: zero findings. + + Kept as the negative twin so the reader can never quietly drift back. + """ + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_seedgo_checklist + + mock_run.return_value = MagicMock(returncode=0, stdout="✗ missing encoding param\n✗ bad import\n") + with patch.dict("os.environ", {"AIPASS_HOME": _FAKE_HOME}): + assert _run_seedgo_checklist(_FAKE_PY) == [] + + @patch("subprocess.run") + def test_a_nonzero_exit_no_longer_discards_the_findings(self, mock_run): + """The exit code carries nothing; stdout is the entire signal. + + checklist does not sys.exit on a standards failure — measured live + 2026-08-30, eight findings and returncode 0. Dropping output on a + non-zero code was the same drift pre-armed for the day it does. + """ + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_seedgo_checklist + + mock_run.return_value = MagicMock(returncode=1, stdout=" [FAIL] — meta: Missing META block\n") + with patch.dict("os.environ", {"AIPASS_HOME": _FAKE_HOME}): + violations = _run_seedgo_checklist(_FAKE_PY) + assert len(violations) == 1 + assert "meta" in violations[0] + + @patch("subprocess.run") + def test_a_wrapped_detail_is_rejoined_not_truncated(self, mock_run): + """Rich wraps at the console width; the reason must survive the fold.""" + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _run_seedgo_checklist + + mock_run.return_value = MagicMock( + returncode=0, + stdout=( + " [FAIL] — architecture: File not in standard 3-layer structure (apps/, \n" + "apps/modules/, apps/handlers/)\n" + " ✓ cli\n" + ), + ) + with patch.dict("os.environ", {"AIPASS_HOME": _FAKE_HOME}): + violations = _run_seedgo_checklist(_FAKE_PY) + assert len(violations) == 1 + assert violations[0].endswith("apps/handlers/)") + + def test_the_marker_is_read_from_seedgo_not_restated_here(self): + """One definition. The next marker change cannot silently disagree.""" + from aipass.seedgo.apps.modules.checklist import FINDING_MARKER + + from aipass.hooks.apps.handlers.lifecycle.auto_fix import _checklist_marker + + assert _checklist_marker() == FINDING_MARKER + + def test_an_unreadable_seedgo_falls_back_loudly(self): + from aipass.hooks.apps.handlers.lifecycle import auto_fix + + with patch.object(auto_fix.importlib, "import_module", side_effect=ImportError("no seedgo")): + with patch.object(auto_fix.logger, "warning") as warned: + marker = auto_fix._checklist_marker() + assert marker == auto_fix._CHECKLIST_MARKER_FALLBACK + warned.assert_called_once() + + def test_a_seedgo_publishing_no_marker_falls_back_loudly(self): + from aipass.hooks.apps.handlers.lifecycle import auto_fix + + with patch.object(auto_fix.importlib, "import_module", return_value=object()): + with patch.object(auto_fix.logger, "warning") as warned: + marker = auto_fix._checklist_marker() + assert marker == auto_fix._CHECKLIST_MARKER_FALLBACK + warned.assert_called_once() @patch("subprocess.run") def test_run_seedgo_skips_claude_hooks(self, mock_run): diff --git a/src/aipass/hooks/tests/test_edit_gate_bash.py b/src/aipass/hooks/tests/test_edit_gate_bash.py new file mode 100644 index 000000000..af50c6514 --- /dev/null +++ b/src/aipass/hooks/tests/test_edit_gate_bash.py @@ -0,0 +1,635 @@ +# =================== AIPass ==================== +# Name: test_edit_gate_bash.py +# Version: 1.0.0 +# Description: Tests for the admin exemption and the scripted (Bash) cross-project lane +# Branch: hooks +# Created: 2026-08-30 +# Modified: 2026-08-30 +# ============================================= + +"""Tests for edit_gate's scripted lane and the devpulse admin exemption. + +Patrick's ruling, 2026-08-30 (devpulse compass 322): the cross-project write +fence stays for every agent and devpulse is the sole exemption — "It is only you +who can reach outwards. Nobody else." + +Two halves, one boundary: + +1. The admin seat passes, but only on a VERIFIED grant. Every test that grants + the exemption also has a twin that withholds the grant from the same seat, so + nothing here can pass on a directory name. +2. Every other seat is blocked on BOTH lanes. The tool lane was already fenced; + the shell lane (sed -i, redirection, tee, cp, a python heredoc) was open to + all 18 citizens until this file. +""" + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +_RAIL = "aipass.ai_mail.apps.handlers.users.verified_caller" + + +@pytest.fixture +def sibling_projects(tmp_path: Path) -> dict: + """Two top-level projects side by side — the real Projects/ layout. + + The pre-existing project fixture nests one project inside the other, which + exercises the upward direction only. Tonight's blocked case was SIDEWAYS: + AIPass and Vera-Studio are siblings, neither contains the other. + + Projects/ + AIPass/ AIPASS_REGISTRY.json + src/aipass/devpulse/ the admin seat + src/aipass/hooks/ an ordinary seat + Vera-Studio/ VERA-STUDIO_REGISTRY.json + .daemon/schedule.json the file @devpulse was refused + """ + projects = tmp_path / "Projects" + + aipass = projects / "AIPass" + (aipass / "src" / "aipass" / "devpulse").mkdir(parents=True) + (aipass / "src" / "aipass" / "hooks").mkdir(parents=True) + (aipass / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") + + vera = projects / "Vera-Studio" + (vera / ".daemon").mkdir(parents=True) + (vera / "VERA-STUDIO_REGISTRY.json").write_text("{}", encoding="utf-8") + schedule = vera / ".daemon" / "schedule.json" + schedule.write_text('{"jobs": []}', encoding="utf-8") + + return { + "aipass": aipass, + "admin_seat": str(aipass / "src" / "aipass" / "devpulse"), + "plain_seat": str(aipass / "src" / "aipass" / "hooks"), + "vera": vera, + "foreign_file": str(schedule), + } + + +def _names(targets, *parts: str) -> bool: + """True when some target ends in these components, in any OS's spelling. + + THE CONTRACT, ruled 2026-08-31: the target list stays OS-NATIVE. These are + the Path objects edit_gate hands to _find_project_root, which globs the real + filesystem for *_REGISTRY.json — a list canonicalised to POSIX spelling + would be unwalkable on Windows, so the fence would go dark in exactly the + place this whole train was fixing. 1.2.0 normalises separators on the way + IN, before pathlib parses a token; it deliberately does not normalise on the + way out. + + So a test may not assert on str(target). @devpulse's windows-setup run + caught two that did — they expected "/other/Project/x.json" and got + "\\other\\Project\\x.json". That spelling is pathlib's own __str__ on + Windows, not anything 1.2.0 did: a rooted-but-driveless path is not + absolute there, and joining it onto the cwd REPLACES the cwd, so the same + two pins were red on Windows before 1.2.0 for the same reason. + + Comparing components says what the test actually means — this command + surrendered that path — in a spelling neither OS owns. + """ + return any(tuple(t.parts[-len(parts) :]) == parts for t, _ in targets) + + +def _run(cwd: str, *, command: str | None = None, file_path: str | None = None, tool: str = "Bash") -> dict: + from aipass.hooks.apps.handlers.security.edit_gate import handle + + tool_input = {"command": command} if command is not None else {"file_path": file_path} + return handle({"tool_name": tool, "tool_input": tool_input, "cwd": cwd}) + + +def _blocked(result: dict) -> bool: + return result["exit_code"] == 2 and json.loads(result["stdout"]).get("decision") == "block" + + +def _reason(result: dict) -> str: + return json.loads(result["stdout"])["reason"] + + +@pytest.fixture +def grant_granted(): + with patch(f"{_RAIL}.is_verified_admin_caller", return_value=True) as m: + yield m + + +@pytest.fixture +def grant_withheld(): + with patch(f"{_RAIL}.is_verified_admin_caller", return_value=False) as m: + yield m + + +class TestAdminExemptionToolLane: + """devpulse's Edit into a sibling project — the write Patrick overruled.""" + + @pytest.mark.parametrize("tool", ["Edit", "Write", "MultiEdit", "NotebookEdit"]) + def test_verified_admin_passes(self, sibling_projects: dict, grant_granted, tool: str): + result = _run(sibling_projects["admin_seat"], file_path=sibling_projects["foreign_file"], tool=tool) + assert result["exit_code"] == 0 + + @pytest.mark.parametrize("tool", ["Edit", "Write", "MultiEdit", "NotebookEdit"]) + def test_admin_seat_without_the_grant_is_blocked(self, sibling_projects: dict, grant_withheld, tool: str): + """The seat is not the credential. Same directory, no grant, refused.""" + result = _run(sibling_projects["admin_seat"], file_path=sibling_projects["foreign_file"], tool=tool) + assert _blocked(result) + assert "Vera-Studio" in _reason(result) + + def test_another_seat_is_blocked_even_with_a_valid_grant_on_the_machine( + self, sibling_projects: dict, grant_withheld + ): + """A machine-wide grant is not a machine-wide licence. + + The rail answers for the CALLER, so a grant that verifies for devpulse + says nothing about @hooks. Withholding here is the rail doing exactly + that — the seat changed, so the answer changed. + """ + result = _run(sibling_projects["plain_seat"], file_path=sibling_projects["foreign_file"], tool="Edit") + assert _blocked(result) + + +class TestAdminExemptionScriptedLane: + """The same ruling, on the lane devpulse actually used under authorization.""" + + def test_verified_admin_passes_sed(self, sibling_projects: dict, grant_granted): + command = f"sed -i 's/false/true/' {sibling_projects['foreign_file']}" + assert _run(sibling_projects["admin_seat"], command=command)["exit_code"] == 0 + + def test_admin_seat_without_the_grant_is_blocked_on_sed(self, sibling_projects: dict, grant_withheld): + command = f"sed -i 's/false/true/' {sibling_projects['foreign_file']}" + assert _blocked(_run(sibling_projects["admin_seat"], command=command)) + + +class TestAdminIdentityIsVerifiedNotClaimed: + """The exemption consumes @ai_mail's rail; it never re-implements or guesses.""" + + def test_the_session_cwd_is_stamped_for_the_rail(self, sibling_projects: dict): + """A hook is not drone-invoked, so it must hand the rail the evidence it reads.""" + seen: dict[str, str] = {} + + def spy() -> bool: + seen["cwd"] = os.environ.get("AIPASS_CALLER_CWD", "") + return True + + with patch(f"{_RAIL}.is_verified_admin_caller", side_effect=spy): + _run(sibling_projects["admin_seat"], file_path=sibling_projects["foreign_file"], tool="Edit") + + assert seen["cwd"] == sibling_projects["admin_seat"] + + def test_the_stamp_does_not_outlive_the_check(self, sibling_projects: dict, grant_granted): + """A hook process is short-lived, but it is not the only thing in it.""" + os.environ.pop("AIPASS_CALLER_CWD", None) + _run(sibling_projects["admin_seat"], file_path=sibling_projects["foreign_file"], tool="Edit") + assert "AIPASS_CALLER_CWD" not in os.environ + + def test_an_existing_caller_stamp_is_never_overwritten(self, sibling_projects: dict, monkeypatch): + """A drone-invoked caller keeps the identity drone gave it.""" + monkeypatch.setenv("AIPASS_CALLER_CWD", "/somewhere/else") + seen: dict[str, str] = {} + + def spy() -> bool: + seen["cwd"] = os.environ.get("AIPASS_CALLER_CWD", "") + return True + + with patch(f"{_RAIL}.is_verified_admin_caller", side_effect=spy): + _run(sibling_projects["admin_seat"], file_path=sibling_projects["foreign_file"], tool="Edit") + + assert seen["cwd"] == "/somewhere/else" + + def test_a_raising_rail_fails_closed(self, sibling_projects: dict): + with patch(f"{_RAIL}.is_verified_admin_caller", side_effect=RuntimeError("registry unreadable")): + result = _run(sibling_projects["admin_seat"], file_path=sibling_projects["foreign_file"], tool="Edit") + assert _blocked(result) + + def test_an_unimportable_rail_fails_closed(self, sibling_projects: dict): + from aipass.hooks.apps.handlers.security import edit_gate + + with patch.object(edit_gate.importlib, "import_module", side_effect=ImportError("no ai_mail")): + result = _run(sibling_projects["admin_seat"], file_path=sibling_projects["foreign_file"], tool="Edit") + assert _blocked(result) + + def test_the_admin_check_is_not_run_for_a_write_that_stays_home(self, sibling_projects: dict): + """Verification touches the disk; a local write must not pay for it.""" + local = str(Path(sibling_projects["admin_seat"]) / "notes.md") + with patch(f"{_RAIL}.is_verified_admin_caller", return_value=True) as rail: + assert _run(sibling_projects["admin_seat"], file_path=local, tool="Edit")["exit_code"] == 0 + rail.assert_not_called() + + +class TestAdminExemptionStaysNarrow: + """Patrick exempted the OUTWARD reach. Nothing else moved.""" + + def test_inbox_writes_are_still_refused_for_the_admin_seat(self, sibling_projects: dict, grant_granted): + inbox = str(Path(sibling_projects["admin_seat"]) / ".ai_mail.local" / "inbox.json") + result = _run(sibling_projects["admin_seat"], file_path=inbox, tool="Write") + assert _blocked(result) + assert "inbox.json" in _reason(result) + + def test_the_exemption_does_not_reach_a_foreign_project_from_an_ordinary_seat( + self, sibling_projects: dict, grant_withheld + ): + command = f"tee {sibling_projects['foreign_file']}" + assert _blocked(_run(sibling_projects["plain_seat"], command=command)) + + +class TestScriptedLaneCatches: + """The write-verbs a shell aims at a foreign project root.""" + + def _cmd_blocked(self, sibling_projects: dict, command: str) -> dict: + return _run(sibling_projects["plain_seat"], command=command) + + def test_sed_in_place(self, sibling_projects: dict, grant_withheld): + assert _blocked(self._cmd_blocked(sibling_projects, f"sed -i s/a/b/ {sibling_projects['foreign_file']}")) + + def test_truncating_redirection(self, sibling_projects: dict, grant_withheld): + assert _blocked(self._cmd_blocked(sibling_projects, f"echo '{{}}' > {sibling_projects['foreign_file']}")) + + def test_appending_redirection(self, sibling_projects: dict, grant_withheld): + assert _blocked(self._cmd_blocked(sibling_projects, f"echo x >> {sibling_projects['foreign_file']}")) + + def test_tee(self, sibling_projects: dict, grant_withheld): + assert _blocked(self._cmd_blocked(sibling_projects, f"echo x | tee {sibling_projects['foreign_file']}")) + + def test_cp_destination(self, sibling_projects: dict, grant_withheld): + assert _blocked(self._cmd_blocked(sibling_projects, f"cp ./local.json {sibling_projects['foreign_file']}")) + + def test_mv_destination(self, sibling_projects: dict, grant_withheld): + assert _blocked(self._cmd_blocked(sibling_projects, f"mv ./local.json {sibling_projects['foreign_file']}")) + + def test_dd_of(self, sibling_projects: dict, grant_withheld): + assert _blocked(self._cmd_blocked(sibling_projects, f"dd if=/dev/zero of={sibling_projects['foreign_file']}")) + + def test_mkdir_into_a_foreign_project(self, sibling_projects: dict, grant_withheld): + target = str(Path(sibling_projects["vera"]) / "newdir") + assert _blocked(self._cmd_blocked(sibling_projects, f"mkdir -p {target}")) + + def test_writing_to_the_foreign_project_root_itself(self, sibling_projects: dict, grant_withheld): + """The boundary walk starts AT the target, not at its parent. + + A bash operand can name a directory, and a project root's parent holds + no registry — so starting one level up reads a write INTO Vera-Studio + as a write into the tree that merely contains it, and allows it. + """ + command = f"cp ./local.json {sibling_projects['vera']}" + assert _blocked(self._cmd_blocked(sibling_projects, command)) + + def test_python_one_liner(self, sibling_projects: dict, grant_withheld): + command = f"python3 -c \"import pathlib; pathlib.Path('{sibling_projects['foreign_file']}').write_text('x')\"" + assert _blocked(self._cmd_blocked(sibling_projects, command)) + + def test_python_heredoc(self, sibling_projects: dict, grant_withheld): + """The exact shape @devpulse named: a heredoc, whose body the lexer mangles.""" + command = ( + "python3 <<'EOF'\n" + "from pathlib import Path\n" + f"Path('{sibling_projects['foreign_file']}').write_text('{{}}')\n" + "EOF" + ) + assert _blocked(self._cmd_blocked(sibling_projects, command)) + + def test_cd_then_relative_write(self, sibling_projects: dict, grant_withheld): + """cd moves the ground the next segment stands on.""" + command = f"cd {sibling_projects['vera']} && sed -i s/a/b/ .daemon/schedule.json" + assert _blocked(self._cmd_blocked(sibling_projects, command)) + + def test_the_refusal_names_the_verb_that_earned_it(self, sibling_projects: dict, grant_withheld): + result = self._cmd_blocked(sibling_projects, f"sed -i s/a/b/ {sibling_projects['foreign_file']}") + assert "sed -i" in _reason(result) + assert "drone @devpulse feedback send" in _reason(result) + + +class TestScriptedLaneDoesNotOverreach: + """A gate that refuses correct commands teaches agents to route around it.""" + + def _run_plain(self, sibling_projects: dict, command: str) -> dict: + return _run(sibling_projects["plain_seat"], command=command) + + def test_reading_a_foreign_file_is_allowed(self, sibling_projects: dict): + assert self._run_plain(sibling_projects, f"cat {sibling_projects['foreign_file']}")["exit_code"] == 0 + + def test_reading_foreign_and_writing_home_is_allowed(self, sibling_projects: dict): + command = f"cat {sibling_projects['foreign_file']} > ./local_copy.json" + assert self._run_plain(sibling_projects, command)["exit_code"] == 0 + + def test_sed_without_in_place_is_a_filter_not_a_write(self, sibling_projects: dict): + assert self._run_plain(sibling_projects, f"sed s/a/b/ {sibling_projects['foreign_file']}")["exit_code"] == 0 + + def test_listing_a_foreign_project_is_allowed(self, sibling_projects: dict): + assert self._run_plain(sibling_projects, f"ls -la {sibling_projects['vera']}")["exit_code"] == 0 + + def test_writing_inside_your_own_project_is_allowed(self, sibling_projects: dict): + assert self._run_plain(sibling_projects, "sed -i s/a/b/ ./README.md")["exit_code"] == 0 + + def test_drone_and_git_name_no_write_verb_this_parser_reads(self, sibling_projects: dict): + """Their fences stay theirs — and this one is not a second opinion on them.""" + for command in ( + f"drone rm {sibling_projects['foreign_file']}", + f"git add {sibling_projects['foreign_file']}", + f"gh api repos/x --input {sibling_projects['foreign_file']}", + ): + assert self._run_plain(sibling_projects, command)["exit_code"] == 0, command + + def test_a_redirection_is_still_caught_when_the_tool_carries_its_own_fence(self, sibling_projects: dict): + """The shell performs that write, not the tool being run.""" + command = f"drone @hooks status > {sibling_projects['foreign_file']}" + assert _blocked(self._run_plain(sibling_projects, command)) + + def test_an_empty_command_is_allowed(self, sibling_projects: dict): + assert self._run_plain(sibling_projects, "")["exit_code"] == 0 + + def test_a_parser_failure_allows_and_says_so(self, sibling_projects: dict): + from aipass.hooks.apps.handlers.security import edit_gate + + with patch.object(edit_gate.importlib, "import_module", side_effect=RuntimeError("boom")): + result = self._run_plain(sibling_projects, f"sed -i s/a/b/ {sibling_projects['foreign_file']}") + assert result["exit_code"] == 0 + + def test_a_seat_outside_any_project_is_not_fenced(self, tmp_path: Path): + """A fence that cannot locate a boundary must not invent one.""" + loose = tmp_path / "loose" + loose.mkdir() + assert _run(str(loose), command="sed -i s/a/b/ /tmp/whatever.txt")["exit_code"] == 0 + + +class TestBashWritesParser: + """Unit-level reading of the parser, independent of the fence.""" + + def test_unbalanced_quotes_fall_back_rather_than_going_quiet(self): + from aipass.hooks.apps.modules.bash_writes import write_targets + + targets = write_targets("echo 'unterminated > /tmp/x.json", "/tmp") + assert any(str(t).endswith("x.json") for t, _ in targets) + + def test_a_variable_path_is_skipped_not_guessed(self): + from aipass.hooks.apps.modules.bash_writes import write_targets + + assert write_targets("sed -i s/a/b/ $TARGET/schedule.json", "/tmp") == [] + + def test_descriptor_duplication_is_not_a_filename(self): + from aipass.hooks.apps.modules.bash_writes import write_targets + + assert write_targets("some_command 2>&1", "/tmp") == [] + + def test_a_quoted_heredoc_body_is_data_not_shell(self): + """Found live: this gate blocked the reply describing its own proof. + + A mail body assembled with ``cat < str: + """Spell a path the way Windows spells it, on whatever OS is reading. + + This is the whole trick that makes the class below runnable anywhere. On + Windows ``str(tmp_path)`` is already backslashed and this is a no-op. On + Linux it turns a real absolute POSIX path into ``\\tmp\\...\\f.json`` — the + exact spelling that killed the parser — and the gate must still resolve it + back to the same real file under the same real fence. The claim under test + is "separators are understood", and that claim is OS-independent even + though drive letters are not. + """ + return str(path).replace("/", "\\") + + +class TestWindowsSpelledPathsAreStillFenced: + """Backslash separators must not walk a foreign write past the fence. + + THE DEFECT (@devpulse, windows-test.yml, 2026-08-31): every test in + TestScriptedLaneCatches failed on Windows CI, all in the ALLOW direction — + exit 0 on `sed -i` into a sibling project. Their reading was "path + extraction or the fence comparison never matches that spelling". The + measurement puts it one layer earlier than either: shlex runs in POSIX + mode, where a backslash is an ESCAPE, so it ate every separator and + `C:\\Users\\me\\Vera-Studio\\f.json` arrived as the single token + `C:UsersmeVera-Studiof.json`. A drive-absolute foreign path became one + relative filename, resolved under the caller's OWN project, and read as a + local write. The extraction and the comparison were both working + correctly on a path that had already been destroyed. + + Reproduced on Linux before the fix, no Windows box involved — which is the + point: the parser never needed a Windows runner to be wrong, it needed a + backslash. The class had never been green on Windows since the day it + shipped, and NOT_CAUGHT did not name it. A category believed caught and + silently uncaught on one OS is worse than a named blind spot. + """ + + def _cmd(self, sibling_projects: dict, command: str) -> dict: + return _run(sibling_projects["plain_seat"], command=command) + + def test_sed_in_place_with_backslash_separators(self, sibling_projects, grant_withheld): + """The exact shape of devpulse's log line: exit 0 where 2 belonged.""" + target = _win(sibling_projects["foreign_file"]) + + result = self._cmd(sibling_projects, f"sed -i s/a/b/ {target}") + + assert _blocked(result), f"backslash-spelled sed -i was ALLOWED: {result}" + assert "Vera-Studio" in _reason(result) + + def test_redirection_with_backslash_separators(self, sibling_projects, grant_withheld): + target = _win(Path(sibling_projects["vera"]) / "notes.txt") + + assert _blocked(self._cmd(sibling_projects, f"echo x > {target}")), "backslash redirection allowed" + + def test_copy_destination_with_backslash_separators(self, sibling_projects, grant_withheld): + target = _win(Path(sibling_projects["vera"]) / "copy.txt") + + assert _blocked(self._cmd(sibling_projects, f"cp local.txt {target}")), "backslash cp allowed" + + def test_interpreter_holding_a_backslash_path(self, sibling_projects, grant_withheld): + """The regex knew only "/" — so on a Windows path it matched NOTHING. + + Not a degraded reading: the interpreter mode, the broadest catch this + parser has, returned an empty list for every Windows-spelled command. + """ + target = _win(sibling_projects["foreign_file"]) + + result = self._cmd(sibling_projects, f"python3 -c \"open('{target}', 'w')\"") + + assert _blocked(result), f"interpreter holding a backslash path was ALLOWED: {result}" + + def test_cd_chain_with_backslash_separators(self, sibling_projects, grant_withheld): + """`cd` moves the ground for the next segment in either spelling.""" + vera = _win(sibling_projects["vera"]) + + result = self._cmd(sibling_projects, f"cd {vera} && tee out.txt") + + assert _blocked(result), f"backslash cd-chain was ALLOWED: {result}" + + def test_directory_target_with_no_extension(self, sibling_projects, grant_withheld): + """`mkdir C:\\Proj\\Vera\\newdir` — no dot anywhere to fall back on. + + Mutation found this one: with the backslash clause removed from + _looks_like_path every other Windows case still passed, because a + filename carries a dot and the dot clause caught it by luck of + spelling. A directory target has no dot, so the separator is the only + evidence that the token is a path at all. + """ + target = _win(Path(sibling_projects["vera"]) / "newdir") + assert "." not in target, f"precondition lost — this path has a dot to fall back on: {target}" + + assert _blocked(self._cmd(sibling_projects, f"mkdir {target}")), "backslash mkdir allowed" + + def test_local_write_stays_allowed_in_both_spellings(self, sibling_projects, grant_withheld): + """The fix must not convict the seat's own project of being foreign. + + Reading a backslash as a separator only ever ADDS path components, so + it cannot move a write upward out of its own project — this pins that + reasoning rather than trusting it. + """ + own = _win(Path(sibling_projects["plain_seat"]) / "mine.txt") + + result = self._cmd(sibling_projects, f"echo x > {own}") + + assert result["exit_code"] == 0, f"a write into the seat's own project was refused: {result}" + + +class TestBothSpellingsAreRead: + """Parser level: neither dialect may be chosen at the other's expense.""" + + def test_posix_escaped_space_survives(self): + """shlex's escape reading is still produced — it is correct for POSIX. + + `cp a\\ b.txt dest` names ONE source file with a space in it. If the + separator reading had replaced the escape reading rather than joining + it, this filename would have become two tokens on every OS. + """ + from aipass.hooks.apps.modules import bash_writes + + readings = bash_writes._readings(r"cp a\ b.txt /tmp/dest.txt") + + assert ["cp", "a b.txt", "/tmp/dest.txt"] in readings, f"escape reading lost: {readings}" + + def test_separator_reading_is_produced_too(self): + """The reading that was missing entirely until 2026-08-31.""" + from aipass.hooks.apps.modules import bash_writes + + readings = bash_writes._readings(r"sed -i s/a/b/ C:\Proj\Vera\f.json") + + assert any(r"C:\Proj\Vera\f.json" in tokens for tokens in readings), ( + f"no reading kept the separators: {readings}" + ) + + def test_a_command_with_no_backslash_is_read_once(self): + """No cost where the dialects agree — and no duplicate refusal lines.""" + from aipass.hooks.apps.modules import bash_writes + + assert len(bash_writes._readings("echo hi > out.txt")) == 1 + + def test_a_target_named_twice_is_reported_once(self): + """Both readings can find the SAME write; the caller is told once. + + The first cut of this test used a Windows-spelled target, where the two + readings produce two DIFFERENT paths and there is nothing to dedupe — + it passed with the dedupe removed. Mutation caught it. The command here + carries a backslash (so both readings run) on something that is not the + target, so both readings agree on `out.txt` and the duplicate is real. + """ + from aipass.hooks.apps.modules import bash_writes + + command = r'python3 -c "print(\"hi\")" > out.txt' + assert len(bash_writes._readings(command)) == 2, "precondition: both readings must run" + + hits = bash_writes.write_targets(command, str(Path.cwd())) + + assert len(hits) == len(set(hits)), f"the same write was reported twice: {hits}" + + def test_separators_alone_are_not_a_path(self): + """Widening the run to accept "\\" made every escaped quote match. + + Harmless to the verdict — filesystem root holds no registry — but a + refusal listing paths the command never named is one nobody believes. + """ + from aipass.hooks.apps.modules import bash_writes + + hits = bash_writes.write_targets(r'python3 -c "print(\"hi\")"', str(Path.cwd())) + + assert all(any(c.isalnum() for c in t.name) for t, _ in hits), f"separator-only target: {hits}" + + def test_the_cross_os_root_limit_is_published(self): + """A drive letter names nothing on Linux — said as data, not discovered.""" + from aipass.hooks.apps.modules import bash_writes + + assert any("operating system" in gap for gap in bash_writes.NOT_CAUGHT), ( + "the cross-OS root residual is not in NOT_CAUGHT" + ) + + +class TestTargetSpellingIsNotPartOfTheContract: + """The two pins @devpulse's windows-setup run left red, pinned in-process. + + Both asserted on str(target) and expected the POSIX spelling. Neither could + ever pass on Windows, and neither failure was about the parser — the target + was correct, the test was reading it through __str__. + """ + + def test_names_accepts_the_windows_rendering(self): + """The exact spelling from the CI log, compared on any OS. + + This is the half that matters: the tests above are green here either + way, so without this one I would be shipping a fix to a Windows failure + with no evidence it addresses the Windows failure. + """ + from pathlib import PureWindowsPath + + as_windows = [(PureWindowsPath(r"\other\Project\x.json"), "python3 (interpreter)")] + + assert _names(as_windows, "other", "Project", "x.json") + + def test_names_does_not_match_a_shorter_tail(self): + """A comparison loose enough to pass anywhere proves nothing.""" + from pathlib import PurePosixPath + + targets = [(PurePosixPath("/somewhere/else/x.json"), "why")] + + assert not _names(targets, "other", "Project", "x.json") + + def test_the_target_list_stays_os_native(self): + """The ruling, pinned: normalise on the way IN, never on the way out. + + edit_gate hands these straight to _find_project_root, which globs the + real filesystem for *_REGISTRY.json. A list canonicalised to one + spelling would be unwalkable on the other OS — the fence would go dark + in precisely the place this train was fixing. + """ + from pathlib import Path as LocalPath + + from aipass.hooks.apps.modules.bash_writes import write_targets + + targets = write_targets("echo x > sub/out.txt", str(LocalPath.cwd())) + + assert targets, "precondition: the command must name a target" + assert all(isinstance(t, LocalPath) for t, _ in targets), ( + f"targets are not the local Path flavour the fence must walk: {targets}" + ) diff --git a/src/aipass/hooks/tests/test_edit_gate_trinity.py b/src/aipass/hooks/tests/test_edit_gate_trinity.py index 9f4c7655a..186da34b3 100644 --- a/src/aipass/hooks/tests/test_edit_gate_trinity.py +++ b/src/aipass/hooks/tests/test_edit_gate_trinity.py @@ -835,25 +835,26 @@ def test_multiedit_replace_all_in_edit(self, tmp_path): class TestTrinityEditUnrelatedFieldOnFatFile: - """REVERSED (DPLAN-0318 circle close): fat legacy entries no longer buy silence. - - This was "THE critical no-false-reject test" while the fleet was mid-migration - and every branch carried over-cap entries it could not yet cure. The fleet - converged (trinity 100 on 21 of 22, push 22/22), so the blanket exemption was - narrowed to todos. An over-cap session or key_learning sitting on disk is now - a violation whether or not this write created it — otherwise a fat entry - written straight to disk reads as "already there" forever. - - The original intent — an uncurable entry elsewhere must not block an - unrelated edit — is not lost, it MOVED to the one container that still - cannot be cured by machine. See TestGrandfatherNarrowedToTodos. + """REVERSED TWICE, and the second reversal restores the original answer. + + Written as "THE critical no-false-reject test", flipped to expect a block on + 2026-08-27 when the fleet had converged, flipped back on 2026-08-30. The + round trip is the lesson, not an embarrassment: the 08-27 premise was that + drift had been cured fleet-wide, and a clause justified by a temporary state + has to be re-read when that state ends. @memory measured that it had ended — + drift RECURS (@ai_mail carried three over-cap learnings the same evening, + @seedgo one) — and that refusing carried debt deadlocks the archiver: + a file cannot get smaller because it is too big. + + So the rule is @memory's entry_limits 1.6.0 rule, and this gate now keeps + the same one: a write is refused for what it AUTHORS, never for what it + CARRIES. Blocking an unrelated edit over a fat entry the agent did not write + was never a standard worth having — it is unsatisfiable by any allowed + action, which is the species this branch has now fixed three times. """ - def test_unrelated_edit_on_fat_file_now_blocked(self, tmp_path): - """Fat legacy sessions + key_learnings, edit touches only a todo. - - Was ALLOWED pre-convergence; now BLOCKED, and the fat entries are named. - """ + def test_unrelated_edit_on_fat_file_is_allowed(self, tmp_path): + """Fat legacy sessions + key_learnings, edit touches only a todo -> ALLOWED.""" from aipass.hooks.apps.handlers.security.edit_gate import handle file_path = _make_trinity_path(tmp_path, "hooks", "local.json") @@ -879,8 +880,8 @@ def test_unrelated_edit_on_fat_file_now_blocked(self, tmp_path): ) ) - assert result["exit_code"] == 2, "an on-disk over-cap entry kept the retired blanket exemption" - assert "key_learnings" in json.loads(result["stdout"])["reason"] + assert result["exit_code"] == 0, "an unrelated edit was refused for entries this write did not author" + assert result["stdout"] == "", "an allowed write must not print a refusal" def test_unrelated_edit_plus_new_over_limit_blocked(self, tmp_path): """Fat file, but Edit ALSO adds a new over-limit entry -> blocked.""" @@ -910,14 +911,18 @@ def test_unrelated_edit_plus_new_over_limit_blocked(self, tmp_path): class TestTrinityEditUnchangedLegacy: - """REVERSED: an untouched over-limit key_learning is now reported. - - "Unchanged" stopped meaning "legacy" the day the fleet converged; it now - means "written and not yet caught". Only todos keep the on-disk exemption. + """REVERSED BACK (2026-08-30): an untouched over-limit entry is carried, not refused. + + "Unchanged" does not mean "legacy" and it does not mean "written and not yet + caught" either — it means THIS WRITE DID NOT AUTHOR IT, which is the only + thing a write gate can honestly judge. Detection of drift already on disk + belongs to drone @memory lint, which reads whole files; a PreToolUse gate + sees only the next write and is structurally blind to drift that arrived + through Bash — as this gate's own refusal text says out loud. """ - def test_edit_unchanged_over_limit_now_blocked(self, tmp_path): - """Over-limit key_learning untouched by the Edit -> blocked anyway.""" + def test_edit_unchanged_over_limit_is_allowed(self, tmp_path): + """Over-limit key_learning untouched by the Edit -> carried, allowed.""" from aipass.hooks.apps.handlers.security.edit_gate import handle file_path = _make_trinity_path(tmp_path, "hooks", "local.json") @@ -939,7 +944,7 @@ def test_edit_unchanged_over_limit_now_blocked(self, tmp_path): ) ) - assert result["exit_code"] == 2, "untouched over-limit entry kept the retired exemption" + assert result["exit_code"] == 0, "a write was refused for an entry it did not author" class TestTrinityWriteDisabled: @@ -960,15 +965,18 @@ def test_disabled_allows_over_limit(self, tmp_path): class TestTrinityWriteUnchangedLegacy: - """REVERSED: rollover-safety is now carried by todos alone. - - A Write that carries an over-cap key_learning forward is blocked even - though it did not author it. Rollover writes stay unblocked where it - matters — todos, the container no machine may prune. + """REVERSED BACK (2026-08-30): rollover-safety is universal, not todos-only. + + This is the exact shape that broke @memory's lane in production: the + extractor removed a tail, wrote the SMALLER document back, and the write was + refused over an entry in the head it never touched — failing identically + every 20 minutes for three hours. The archiver is always on the losing side + of that trade. Carrying an over-cap entry forward while adding a clean one + is a write that authored nothing over cap, and it is allowed. """ - def test_unchanged_over_limit_now_blocked(self, tmp_path): - """Over-limit entry identical in before/after -> blocked anyway.""" + def test_unchanged_over_limit_is_allowed(self, tmp_path): + """Over-limit entry identical in before/after -> carried, allowed.""" from aipass.hooks.apps.handlers.security.edit_gate import handle file_path = _make_trinity_path(tmp_path, "hooks", "local.json") @@ -983,7 +991,7 @@ def test_unchanged_over_limit_now_blocked(self, tmp_path): with patch("importlib.import_module", return_value=_mock_entry_limits(_TEST_LIMITS_ENFORCE)): result = handle(_hook_data(file_path, content, cwd=cwd)) - assert result["exit_code"] == 2, "carried-forward over-limit entry kept the retired exemption" + assert result["exit_code"] == 0, "a rollover-shaped write was refused for what it carried" def test_changed_legacy_blocked(self, tmp_path): """Legacy entry modified (text changed, still over-limit) -> blocked.""" @@ -2239,15 +2247,15 @@ def test_empty_string_is_not_a_missing_field(self, tmp_path): class TestRenamedFieldLegacyAsymmetry: """Only NEW or EDITED entries are refused — untouched legacy shapes pass.""" - def test_untouched_legacy_entry_now_refused(self, tmp_path): - """REVERSED by the circle close — the fleet reset happened. + def test_untouched_legacy_entry_is_carried_not_refused(self, tmp_path): + """REVERSED BACK (2026-08-30) — and the round trip is the record. - This test was written the night B3 landed, when nine-plus branches held - legacy shapes and refusing them would have bricked memory writes - fleet-wide. The push then cured them (22/22), so the exemption's own - premise expired. A drifted key_learning on disk is now refused whether - or not this write authored it; todos are the only container that keeps - the on-disk pass. + Written the night B3 landed, flipped on 08-27 because the push had cured + the fleet (22/22), flipped back now because @memory measured that drift + RECURS and that refusing carried debt deadlocks their rollover. The + 08-27 premise was a temporary state treated as permanent. A write is + refused for what it AUTHORS, never for what it CARRIES — the NEW + canonical entry on top is judged; the untouched legacy one is not. """ from aipass.hooks.apps.handlers.security.edit_gate import handle @@ -2262,7 +2270,7 @@ def test_untouched_legacy_entry_now_refused(self, tmp_path): with patch("importlib.import_module", return_value=_mock_entry_limits(_LIMITS_LIST_ENFORCE)): result = handle(_hook_data(file_path, content, cwd=cwd)) - assert result["exit_code"] == 2, "untouched drifted key_learning kept the retired exemption" + assert result["exit_code"] == 0, "a write was refused for a drifted entry it did not author" def test_editing_a_legacy_entry_refuses_it(self, tmp_path): """Touch the drifted entry and you own its shape.""" @@ -2404,24 +2412,28 @@ def test_over_cap_and_missing_field_both_reported(self, tmp_path): # --------------------------------------------------------------------------- -# Grandfather narrowing — todos only (DPLAN-0318 circle close) +# Authored vs carried — the on-disk pass is universal (2026-08-30) # --------------------------------------------------------------------------- # -# The blanket exemption existed so a drifted fleet would not be bricked -# mid-migration. The fleet converged (trinity 100 on 21 of 22, push 22/22), so -# the clause now protects nothing real and hides everything new: a drifted -# entry written straight to disk reads as "already there" on the next write and -# never surfaces again. +# I narrowed the exemption to todos on 08-27 on two beliefs: the fleet had +# converged, so the clause protected nothing real; and "unchanged and over cap +# passes" hid new drift. @memory disproved both from the outside — their +# rollover lane failed identically every 20 minutes for three hours because the +# extractor removed a tail, wrote the SMALLER document back, and a write gate +# refused the whole file over an entry in the head it never touched. The +# archiver is always on the losing side of that trade: the file cannot get +# smaller because it is too big. # -# todos keep it, and only todos. The push is forbidden to archive open work -# (@memory 1.1.0, after it archived 67 todos fleet-wide and had to mail them -# back), so nothing but the branch's own agent can ever cure a drifted todo. -# Refusing every write to such a file would brick its rollover — preserving the -# debt by destroying the lane that preserves everything else. +# So the rule is @memory's, and it is universal: a write is refused for what it +# AUTHORS, never for what it CARRIES. My own refusal text is the other half of +# the evidence — writes made through Bash are not checked, so a write gate is +# structurally blind to how drift ARRIVES and was never the thing that could +# detect it. That job is drone @memory lint, which reads the file. # -# The list is read off @memory's RESHAPE_ONLY_SECTIONS at call time rather than -# restated here: two lists of "containers we may not prune" would disagree -# within a release. +# Carried drift is REPORTED at INFO, never refused. Identity is the raw entry, +# never its index: a prepend shifts every position down, and an index-keyed +# diff would call the whole file newly authored on exactly the write that +# authored nothing. _LIMITS_TODOS = { @@ -2450,16 +2462,16 @@ def _write_before(file_path, payload): Path(file_path).write_text(json.dumps(payload), encoding="utf-8") -class TestGrandfatherNarrowedToTodos: - """Untouched drift is refused everywhere EXCEPT todos.""" +class TestCarriedDriftIsNotRefused: + """Untouched drift is CARRIED everywhere — reported, never refused.""" - def test_untouched_drifted_key_learning_now_refused(self, tmp_path): - """REVERSAL of the mid-migration exemption. + def test_untouched_drifted_key_learning_is_carried_not_refused(self, tmp_path): + """REVERSED BACK (2026-08-30): the on-disk pass is universal, not todos-only. - Before the fleet converged this entry was allowed to sit untouched. - Now it is a violation whether or not this write created it — the fleet - is canonical, so "unchanged" no longer means "legacy", it means - "written and not yet caught". + "Unchanged" means this write did not author it — the only thing a write + gate can honestly judge. Reading whole files for drift already on disk + is drone @memory lint's job, and it is the only component that can + actually see drift that arrived through Bash. """ from aipass.hooks.apps.handlers.security.edit_gate import handle @@ -2475,8 +2487,8 @@ def test_untouched_drifted_key_learning_now_refused(self, tmp_path): with patch("importlib.import_module", return_value=_mock_entry_limits(_LIMITS_TODOS)): result = handle(_hook_data(file_path, content, cwd=cwd)) - assert result["exit_code"] == 2, "untouched drifted key_learning kept its exemption" - assert "value" in json.loads(result["stdout"])["reason"] + assert result["exit_code"] == 0, "a write was refused for a drifted entry it did not author" + assert result["stdout"] == "", "carried drift must be reported in the log, never in a block" def test_untouched_drifted_todo_still_passes(self, tmp_path): """THE CAUTION: a drifted todo must not brick a write to another section. @@ -2536,56 +2548,72 @@ def test_editing_a_drifted_todo_refuses_it(self, tmp_path): assert result["exit_code"] == 2, "an edited drifted todo kept its exemption" -class TestReshapeOnlySeam: - """The exemption list is @memory's, read at call time — never a local copy.""" +class TestAuthoredVsCarriedUnit: + """Unit-level, because handle() cannot see this half fail. - def test_reads_memorys_constant_not_a_local_copy(self): - """Two lists of 'containers we may not prune' would disagree in a release. + Mutation found the gap (2026-08-30): making _missing_field_violations + return NOTHING left all 115 end-to-end tests green. The union in + _evaluate_limits adds @memory's real changed_entries, which refuses a new + missing-field entry through its own lane — so every through-handle() test + that proves "authored drift is refused" passes for the wrong reason and + would keep passing if this checker were deleted outright. The overlap is + deliberate; a test suite that cannot tell the two halves apart is not. + """ - Unit-level on purpose. Going through handle() cannot prove this: the - union also runs @memory's REAL changed_entries, which reads @memory's - real module constant and would refuse the entry no matter what this - gate decided. Isolating this half is the only way to see whose list it - is reading — a wider end-to-end test here would pass for the wrong - reason and prove nothing. - """ - legacy = {"number": 1, "date": "2026-08-01", "learning": "x" * 500} - before = {"key_learnings": [legacy]} - after = {"key_learnings": [legacy]} + def test_a_new_missing_field_entry_is_refused(self): + """The authored half, isolated from @memory's overlapping refusal.""" + before = {"key_learnings": []} + after = {"key_learnings": [{"number": 1, "date": "2026-08-30", "learning": "renamed field"}]} - narrow = MagicMock() - narrow.RESHAPE_ONLY_SECTIONS = ("todos",) - assert edit_gate._missing_field_violations(before, after, _LIMITS_TODOS, narrow), ( - "key_learnings is not in the exemption list but the on-disk entry was skipped" - ) + hits = edit_gate._missing_field_violations(before, after, _LIMITS_TODOS) + + assert len(hits) == 1, "a newly authored entry with no 'value' field was not refused" + assert hits[0]["reason"] == "missing_field" + assert hits[0]["field"] == "value" + assert hits[0]["key"] == "0", "keys are rendered as strings — @memory's six-key contract" - widened = MagicMock() - widened.RESHAPE_ONLY_SECTIONS = ("todos", "key_learnings") # @memory widened it - assert edit_gate._missing_field_violations(before, after, _LIMITS_TODOS, widened) == [], ( - "gate ignored @memory's widened exemption — it carries its own copy" + def test_an_identical_on_disk_entry_is_carried(self): + """Byte-identical to disk: this write did not author it.""" + legacy = {"number": 1, "date": "2026-08-01", "learning": "renamed field"} + + hits = edit_gate._missing_field_violations( + {"key_learnings": [legacy]}, {"key_learnings": [legacy]}, _LIMITS_TODOS ) - def test_absent_constant_falls_back_to_todos_and_says_so(self, caplog): - """An @memory too old to publish the list must not brick the fleet. + assert hits == [], "a write was refused for an entry it carried unchanged" + + def test_carrying_one_does_not_license_authoring_another(self): + """The mixed case names the new entry and only the new entry. - Exempting nothing would refuse every write to a file carrying one - drifted todo — the exemption bricking the branch it protects. Falls - back to todos and logs, because a silent fallback is the failure mode - this whole standard exists to end. + This is the test the two survivors could not have been caught without: + it needs the checker to both skip AND refuse in the same call, so no + blanket answer — refuse everything, refuse nothing — satisfies it. """ - drifted = {"priority": "low", "status": "open", "chore": "reshape me"} - before = {"todos": [drifted]} - after = {"todos": [drifted]} + legacy = {"number": 1, "date": "2026-08-01", "learning": "carried"} + fresh = {"number": 2, "date": "2026-08-30", "learning": "authored in the same shape"} - ancient = MagicMock(spec=[]) # publishes no RESHAPE_ONLY_SECTIONS + hits = edit_gate._missing_field_violations( + {"key_learnings": [legacy]}, {"key_learnings": [fresh, legacy]}, _LIMITS_TODOS + ) - with caplog.at_level(logging.WARNING): - hits = edit_gate._missing_field_violations(before, after, _LIMITS_TODOS, ancient) + assert len(hits) == 1, f"expected exactly the authored entry to be refused, got {hits}" + assert hits[0]["key"] == "0", "the refusal named the carried entry's position, not the authored one" + + def test_a_prepend_does_not_reauthor_the_whole_file(self): + """Identity is the entry text, never its index. - assert hits == [], "fallback did not exempt todos" - assert "RESHAPE_ONLY_SECTIONS" in caplog.text, "fell back silently" + A prepend shifts every position down. An index-keyed diff reads the + entire file as newly authored on exactly the write that authored + nothing new below the head. + """ + carried = [ + {"number": 1, "date": "2026-08-01", "learning": "a"}, + {"number": 2, "date": "2026-08-02", "learning": "b"}, + ] + clean_head = {"number": 3, "date": "2026-08-30", "value": "measurable"} + + hits = edit_gate._missing_field_violations( + {"key_learnings": carried}, {"key_learnings": [clean_head, *carried]}, _LIMITS_TODOS + ) - def test_live_constant_is_todos_only(self): - """Guard on the real module: the shipped ruling is todos and only todos.""" - el = importlib.import_module("aipass.memory.apps.handlers.json.entry_limits") - assert el.RESHAPE_ONLY_SECTIONS == ("todos",) + assert hits == [], f"a prepend re-authored entries it only shifted: {hits}" diff --git a/src/aipass/hooks/tests/test_import_dead_cwd.py b/src/aipass/hooks/tests/test_import_dead_cwd.py new file mode 100644 index 000000000..ea4c309c5 --- /dev/null +++ b/src/aipass/hooks/tests/test_import_dead_cwd.py @@ -0,0 +1,360 @@ +# =================== AIPass ==================== +# Name: test_import_dead_cwd.py +# Description: Pins hooks imports against a dead working directory (two worlds) +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Every hooks module must import without a readable working directory. + +THE MECHANISM, measured on the Windows CI gate 2026-08-31 (@memory's finding, +routed by @devpulse): ``ntpath.realpath`` calls ``os.getcwd()`` +UNCONDITIONALLY - not only for relative paths, the way ``posixpath`` does - +and ``Path.resolve()`` routes through it. So on Windows every +``Path(__file__).resolve()`` reached at import time is an import-time +working-directory dependency: a process whose cwd is gone cannot import the +module at all. + +WHY THIS BRANCH NEEDED TWO WORLDS. @seedgo measured the asymmetry and it is +real here: one instrument proves half the defect and looks complete. + + World A - ntpath emulation. ``os.path.realpath`` is wrapped to read + ``os.getcwd()`` first, then ``os.getcwd`` is denied. This convicts an + unguarded ``Path(__file__).resolve()``. It does NOT convict + ``inspect.stack()`` on Linux: there the raise happens inside + ``getabsfile()``, where inspect catches it. + + World B - ``os.path.realpath`` denied directly, ``abspath`` left working. + This convicts ``inspect.stack()`` at ``inspect.py:1009``. + +THE ARMING INGREDIENT FOR WORLD B, measured here rather than assumed: +``inspect.stack()`` only reaches ``os.path.realpath`` for a frame whose +filename does not exist on disk. ``getsourcefile()`` returns early for a real +file (``os.path.exists``) and returns early for anything already in +``linecache.cache``; only the remaining case falls through to ``getmodule()``, +whose module-cache rebuild loop contains the bare +``modulesbyfile[os.path.realpath(f)]`` at line 1009. + +That is why a first cut of this file reported the world VACUOUS while the very +same world killed the import: the probe ran from ````, which the +heredoc had put in ``linecache.cache``, so it took the early return. A +```` frame from ``compile()`` is not cached and does fall through - +and so do the ```` frames present on any real +import, which is what the live defect actually rides. The probe below uses +```` deliberately; ```` would silently measure nothing. +""" + +import ast +import subprocess +import sys +from pathlib import Path + +import pytest + +BRANCH_ROOT = Path(__file__).resolve().parents[1] +GUARD_SOURCE = BRANCH_ROOT / "apps" / "handlers" / "__init__.py" + +# Other branches' import-time code is held CONSTANT: preloaded in the healthy +# world, before any denial. Their dead-cwd cure is their own build (fleet +# rollout in flight, 2026-08-31); this pin measures hooks' sites only. When the +# fleet is cured these preloads can drop. +_PRELOAD = """ +import aipass.prax # noqa: F401 +import aipass.prax.apps.modules.logger # noqa: F401 +import aipass.cli.apps.modules # noqa: F401 +""" + +_WORLD_A = """ +import os + +_real_realpath = os.path.realpath + + +def _ntpath_condition(path, **kw): + os.getcwd() # ntpath.realpath reads cwd before checking absoluteness + return _real_realpath(path, **kw) + + +os.path.realpath = _ntpath_condition + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd + +# Probe the instrument: does THIS interpreter's resolve() reach the denied +# call for an absolute path? 3.11+ routes through os.path.realpath; 3.10 +# resolves absolute paths without cwd, so the denial cannot fire there. +import pathlib + +try: + pathlib.Path(pathlib.__file__).resolve() + print("PROBE_VACUOUS") +except FileNotFoundError: + print("PROBE_ARMED") +""" + +_WORLD_B = """ +import os + + +def _denied_realpath(path, **kw): + raise FileNotFoundError(2, "realpath denied", "") + + +os.path.realpath = _denied_realpath +# os.path.abspath stays WORKING - world B denies realpath only, so a cure that +# merely swaps one for the other is not accidentally blessed here. + +# Probe the instrument from a frame: not on disk and not in +# linecache, so getsourcefile falls through to getmodule and reaches the +# denied call - the same fall-through the frozen importlib frames of a real +# import take. A frame IS linecached and would report vacuity. +try: + exec(compile("import inspect; inspect.stack()", "", "exec"), {}) + print("PROBE_VACUOUS") +except FileNotFoundError: + print("PROBE_ARMED") +""" + + +def _module_names() -> list[str]: + """Every importable module under this branch's apps/ tree. + + Discovered, never listed: a hand-written list silently stops covering the + handler added after it was written. + + Returns: + Dotted module names, sorted. + """ + names = set() + for path in (BRANCH_ROOT / "apps").rglob("*.py"): + relative = path.relative_to(BRANCH_ROOT.parents[1]).with_suffix("") + parts = list(relative.parts) + if parts[-1] == "__init__": + parts.pop() + names.add(".".join(parts)) + return sorted(names) + + +def _run_world(world: str, imports: str) -> subprocess.CompletedProcess: + """Run a denial world in a child process. + + The injection happens in a child before any aipass import, so no module + has cached the real functions. + + Args: + world: The denial preamble (world A or world B). + imports: The import statements to execute under it. + + Returns: + The finished child process. + """ + script = _PRELOAD + world + imports + '\nprint("IMPORTED")\n' + return subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=300, + cwd=str(BRANCH_ROOT.parents[2]), + ) + + +def _assert_world_was_armed(out: str) -> None: + """A world that could not fire proves nothing; say so rather than pass. + + Args: + out: The child's stdout. + """ + if "PROBE_VACUOUS" in out: + # Allowed only where it is the interpreter's truth (pre-3.11 pathlib + # never routes an absolute resolve through os.path.realpath). + assert sys.version_info < (3, 11), ( + "the denial did not fire on an interpreter that routes through " + "os.path.realpath - the instrument is broken, not the world" + ) + else: + assert "PROBE_ARMED" in out, f"the instrument reported neither outcome:\n{out}" + + +class TestEveryModuleImportsWithoutACwd: + """The whole tree, both worlds. One import statement per module in one + child: a module that dies takes the run down and names its own line.""" + + IMPORTS = "\n".join(f"import {name} # noqa: F401" for name in _module_names()) + + def test_world_a_ntpath_emulation(self): + """Convicts an unguarded Path(__file__).resolve() reached at import.""" + result = _run_world(_WORLD_A, self.IMPORTS) + _assert_world_was_armed(result.stdout) + assert "IMPORTED" in result.stdout, ( + f"an import died with cwd unreadable:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + def test_world_b_realpath_denied(self): + """Convicts inspect.stack() at inspect.py:1009.""" + result = _run_world(_WORLD_B, self.IMPORTS) + _assert_world_was_armed(result.stdout) + assert "IMPORTED" in result.stdout, ( + f"an import died with realpath denied:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + def test_the_module_list_is_not_empty(self): + """Positive control. Both tests above pass trivially over no imports - + a broken discovery walk would report a cured tree that was never read.""" + names = _module_names() + assert len(names) > 40, f"module discovery found only {len(names)}: {names}" + assert "aipass.hooks.apps.modules.engine" in names + assert "aipass.hooks.apps.handlers.security.edit_gate" in names + + def test_the_worlds_are_not_the_same_world(self): + """World B must deny realpath OUTRIGHT; world A must deny getcwd. If a + later edit collapsed them into one, the asymmetry this file exists for + would be gone and both tests would prove the same half.""" + assert "os.getcwd = _dead_getcwd" in _WORLD_A + assert "os.getcwd" not in _WORLD_B.replace("os.getcwd = _dead_getcwd", "") + assert "_denied_realpath" in _WORLD_B + + +class TestTheCureCannotBeSilentlyReverted: + """``inspect.stack()`` in the guard is the defect, not a style preference. + + ROLLOUT REQUIREMENT from @memory: an import probe cannot reach the + caller-is-None branch, so the import tests above can never see a + reintroduced ``inspect.stack()`` there. The ban has to be structural. + """ + + def test_the_guard_source_is_actually_read(self): + """Positive control for the checks below: a wrong path would let them + all pass while reading nothing.""" + text = GUARD_SOURCE.read_text(encoding="utf-8") + assert "_find_real_caller" in text and "_guard_branch_access" in text + + @staticmethod + def _inspect_stack_calls(source: str) -> list[int]: + """Lines calling ``inspect.stack()``, by shape. + + AST, not grep: this guard's docstrings NAME the defect, and a string + ban convicts the explanation while acquitting the code. + + Args: + source: Python source text. + + Returns: + Line numbers of matching calls. + """ + tree = ast.parse(source) + return [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "stack" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "inspect" + ] + + def test_inspect_stack_is_gone_from_the_guard(self): + lines = self._inspect_stack_calls(GUARD_SOURCE.read_text(encoding="utf-8")) + assert lines == [], ( + "inspect.stack() reaches os.path.realpath, which needs a readable cwd " + f"on Windows - walk frames with sys._getframe instead (line {lines})" + ) + + def test_that_ban_would_convict_a_real_call(self): + """Negative control. A matcher that matches nothing passes on every + file, including a reverted one.""" + assert self._inspect_stack_calls("import inspect\ndef f():\n return inspect.stack()\n") == [3] + + def test_that_ban_acquits_the_docstring_that_names_it(self): + """Negative control for the other direction: the shape rule must not + convict prose. This is why the ban is AST and not a grep.""" + assert self._inspect_stack_calls('"""We do not call inspect.stack() here."""\n') == [] + + def test_the_frame_walk_is_what_replaced_it(self): + """Names the cure, so 'delete inspect and break the guard' is not a way + to make the ban above pass.""" + text = GUARD_SOURCE.read_text(encoding="utf-8") + assert "sys._getframe" in text + assert "linecache" in text, "code_context needs a replacement, not deletion" + + def test_the_guards_own_resolve_is_guarded(self): + """The world-A conviction site. An unguarded resolve in this file is + reached by EVERY hooks import, because apps/__init__.py imports + handlers - so this one line decides the whole branch.""" + tree = ast.parse(GUARD_SOURCE.read_text(encoding="utf-8")) + unguarded = [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "resolve" + and not _inside_try(tree, node) + ] + assert unguarded == [], f"resolve() outside a try/except in the guard (line {unguarded})" + + +def _try_body_nodes(tree: ast.AST) -> set[int]: + """Identities of every node inside some ``try`` body. + + Args: + tree: The parsed module. The caller must keep it alive - these are + ``id()`` values, and a collected tree could reuse them. + + Returns: + ``id()`` of each node within a ``try`` body. + """ + inside: set[int] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Try): + continue + for statement in node.body: + inside.update(id(child) for child in ast.walk(statement)) + return inside + + +def _inside_try(tree: ast.AST, target: ast.AST) -> bool: + """Whether a node sits inside the body of a ``try`` statement. + + Args: + tree: The parsed module. + target: The node to locate. + + Returns: + True when the node is within some ``try`` body. + """ + return id(target) in _try_body_nodes(tree) + + +class TestTheInsideTryHelperIsNotVacuous: + """Negative control for the helper the resolve ban depends on. A helper + that always returned True would acquit an unguarded call silently.""" + + def test_it_sees_a_guarded_call(self): + tree = ast.parse("from pathlib import Path\ntry:\n Path('x').resolve()\nexcept OSError:\n pass\n") + call = next(n for n in ast.walk(tree) if isinstance(n, ast.Call)) + assert _inside_try(tree, call) is True + + def test_it_sees_an_unguarded_call(self): + tree = ast.parse("from pathlib import Path\nPath('x').resolve()\n") + call = next(n for n in ast.walk(tree) if isinstance(n, ast.Call)) + assert _inside_try(tree, call) is False + + def test_a_call_in_the_except_body_is_not_guarded(self): + """The fallback itself must not resolve - that is the same crash one + line later. Only the try BODY counts as guarded.""" + tree = ast.parse("from pathlib import Path\ntry:\n pass\nexcept OSError:\n Path('x').resolve()\n") + call = next(n for n in ast.walk(tree) if isinstance(n, ast.Call)) + assert _inside_try(tree, call) is False + + +@pytest.mark.parametrize("world_name", ["A", "B"]) +def test_the_ordinary_import_still_works(world_name): + """A guard that refused everything, or a world that killed the interpreter + before any hooks code ran, would pass every denial test above.""" + world = {"A": _WORLD_A, "B": _WORLD_B}[world_name] + result = _run_world(world, "import aipass.hooks.apps.modules.engine # noqa: F401") + assert "IMPORTED" in result.stdout, result.stderr diff --git a/src/aipass/memory/.seedgo/bypass.json b/src/aipass/memory/.seedgo/bypass.json index c12b71130..bd2a9d82f 100644 --- a/src/aipass/memory/.seedgo/bypass.json +++ b/src/aipass/memory/.seedgo/bypass.json @@ -3,7 +3,7 @@ "version": "1.0.0", "created": "2026-03-07T23:23:54.979418", "description": "Standards bypass configuration for this branch", - "last_updated": "2026-08-16" + "last_updated": "2026-08-31" }, "bypass": [ { @@ -578,6 +578,14 @@ "file": "apps/handlers/cli/json_flag.py", "standard": "json_structure", "reason": "Pure argument-inspection predicate -- no I/O, no state, no branch imports by design, the --json sibling of help_flags.py above. Runs on every command invocation, so log_operation() here would write a line per CLI call and drown the operation log." + }, + { + "file": "apps/handlers/json/entry_limits.py", + "standard": "unused_function", + "functions": [ + "changed_entries" + ], + "reason": "Cross-branch entry point the branch-local scan cannot see: @hooks calls it by name via importlib in apps/handlers/security/edit_gate.py (the entry-cap half of the write gate for the whole fleet). Deleting it to satisfy the checker disables that gate. Narrowed to this one function, and the claim is pinned by tests/test_changed_entries.py::TestTheBypassClaimIsMeasuredNotAsserted so the bypass goes red the day @hooks stops calling it." } ], "notes": { diff --git a/src/aipass/memory/README.md b/src/aipass/memory/README.md index 96850936b..ce86cb8d7 100644 --- a/src/aipass/memory/README.md +++ b/src/aipass/memory/README.md @@ -73,6 +73,12 @@ drone @memory lint run # Audit .trinity entries for over-lim drone @memory lint @devpulse # Lint a specific branch drone @memory lint # Bare = introspection banner, NOT a scan +drone @memory roots list # The declared repository roots + whether each is reachable +drone @memory roots init # Create AIPASS_ROOTS.json from the template — refuses to overwrite +drone @memory roots add ../wren "label" # Declare a root (relative to AIPass home when it is a sibling) +drone @memory roots remove ../wren # Retire a root — the fleet stops reaching it +drone @memory roots heal # DELIBERATE repair of a corrupt anchor — never automatic + drone @memory verify FPLAN-XXXX # Check if plan is vectorized in ChromaDB drone @memory watch # Auto-rollover watcher daemon (Ctrl+C to stop) ``` @@ -115,7 +121,7 @@ memory/ │ ├── vector/ # embed_subprocess.py (embedder.py PARKED 2026-08-14) │ └── central_writer.py ├── templates/ # LOCAL.template.json, OBSERVATIONS.template.json -├── tests/ # 1323 test functions on disk — 1222 collected, 1222 pass, 5 skip +├── tests/ # 1471 test functions on disk — 1428 collected, 1428 pass, 5 skip ├── .chroma/ # ChromaDB vector store └── memory_json/ # Operation logs + custom_config/memory.config.json ``` @@ -158,21 +164,51 @@ It is **scoped to the branches this run actually rolled**, like the tab refresh that handler on purpose. A rolled branch the fleet scope cannot see is logged by name, never silently skipped. -### One definition of the fleet (2026-08-27) +### One definition of the fleet (2026-08-27 → 2026-08-30) -`handlers/monitor/registry_scope.py` answers "which branches are ours" once: `CORE_REGISTRY` plus -four named `RESIDENT_REGISTRIES` (`baud`, `earmark`, `finch`, `aipass-site`). -`detector._read_registry()` reads the residents from that constant, so rollover, lint and health now -reach the same **22** branches the trinity push always did. Measured live: `_read_registry()` returns -22 branches, and `rollover report-lines` measures **44** files (22 × 2). +`handlers/monitor/registry_scope.py` answers "which branches are ours" once, for three tiers: -Before it, a resident arrived only if some caller's cwd had once persisted its registry into -`known_registries.json` — `baud` was in there by accident of where somebody happened to stand; -`earmark`, `finch` and `aipass_site` were not, so three citizens' memory files could overflow with no -rollover ever running on them. The list is a **named constant, never a glob** over `projects/`, which -also holds `marketstand(on _hold)` — a project whose registry says `active` inside a directory whose -name says otherwise. Caller discovery (`detector._find_caller_registries`) is a separate mechanism and -is unchanged. +| tier | where it is found | what makes it a member | +|---|---|---| +| `core` | `AIPASS_REGISTRY.json` at the repo root | listed and `active` | +| `resident` | `projects/*/*_REGISTRY.json` | its passport declares `citizenship.residency: "resident"` | +| `external` | a registry at the top level of a **declared** root | a passport EXISTS — presence, not declaration | + +`modules/fleet.py` is the public door; cross-branch callers import from there, never from the handler. +Measured live: **28** citizens — 18 core, 4 resident, 6 external across 4 declared roots. + +The residents used to be a named four-tuple, and before that a glob over `projects/`. Both were +replaced by the passport rule: a project joins by saying so in its own `.trinity/passport.json`, which +is why `marketstand(on _hold)` — `active` in a registry inside a directory whose name says otherwise — +needs no special case. Before any of it, a resident arrived only if some caller's cwd had once +persisted its registry into `known_registries.json`, so three citizens' memory files could overflow +with no rollover ever running on them. + +**The walk law:** one shallow glob at a declared root's top level for `*_REGISTRY.json`, then that +registry's own branches. Never a passport walk — `projects/` holds 8 passports for 4 residents, +because `@baud` carries `.backup/` copies. A root that overlaps AIPass home is refused in either +direction; a root holding several registries is a named refusal, not a `sorted()[0]` pick. + +**Declaration order is the order** (4.1.0). `declared_roots()` used to `sorted()` its result, so an +N-root tie arrived at every door as alphabetical-by-resolved-path — an accident of what someone named +a directory — while the fleet ruling breaks that tie by *declaration* order. Both are deterministic; +only the rows a human wrote carry intent. @ai_mail found it by noticing the order at their door could +not be the order the ruling names, and reported the disagreement instead of guessing at it. The reader +moved, not the ruling: rows come back in file order, first declaration wins a duplicate. On this +machine every one of the four roots changed position. + +### The external tier declares itself (2026-08-30) + +`AIPASS_ROOTS.json` sits beside `AIPASS_REGISTRY.json` and is the same species: blessed, the trust +anchor of a tier. Paths are relative to AIPass home so `/home/` stays out of a public repo. +`handlers/monitor/roots_file.py` is the write half behind `drone @memory roots` — it refuses to +overwrite an existing anchor (unreadable is not absent), and `heal` is a verb you type, never +something that happens to you: it preserves the original bytes to `AIPASS_ROOTS.json.corrupt`, +reports the path-shaped strings it can see, and writes none of them back. + +**One consumer subtracts a tier on purpose.** `trinity_push.resolve_scope()` drops `external`, because +every other consumer of `fleet_branches()` reads and the push WRITES. Nothing in the external tier +build writes into another repository, and the push is not the exception. ### Safety valve and the two session lanes @@ -411,8 +447,9 @@ The refusal payload keeps `length`/`cap`/`over_by` as ints (the @hooks `edit_gat `%d`) and adds `reason: "unmeasurable"` and `found_type`. When this shipped, **unchanged legacy entries still passed** — `enforce: true` was live and 9 branches carried list-shaped notes, so only a *new or edited* unmeasurable entry was refused and the fix could not brick the fleet it was meant to -protect. That exemption has since been narrowed to `todos` alone — see -[The grandfather clause, narrowed to todos](#the-grandfather-clause-narrowed-to-todos-2026-08-27). +protect. That exemption was narrowed to `todos` alone on 2026-08-27 and widened back to every container on +2026-08-30 — see +[Judged by what it authors, not by what it carries](#judged-by-what-it-authors-not-by-what-it-carries-2026-08-30). #### …and a field it cannot FIND is the same species (fixed 2026-08-26) @@ -437,23 +474,61 @@ the reading that shape belongs to the trinity checker rather than to a char-cap more than it bought: the write gate refuses those entries, so a branch could be told it was compliant and then be blocked on its next write for a shape lint had already seen. `run_lint` now names them. -#### The grandfather clause, narrowed to todos (2026-08-27) +#### Judged by what it authors, not by what it carries (2026-08-30) + +**A write is refused for the text it wrote, never for the text it is holding.** `classify_entries` +splits every over-cap entry in one traversal: `["authored"]` — new or edited, and the only thing a +write may be refused for — and `["carried"]` — byte-identical to what is on disk, reported and let +through. `changed_entries` is the authored half alone, kept under that name because @hooks' +`edit_gate` calls it; the six-key payload is unchanged. + +This reverses a narrowing made on 2026-08-27, and the reversal was expensive to learn. That change +exempted `todos` alone, reasoning that the trinity push had cured legacy drift fleet-wide so +"unchanged and over cap passes" now hid new drift instead of protecting old. Both halves were wrong +about the world: + +- **Drift recurs.** The evening the deadlock was reported, @ai_mail carried three over-cap + `key_learnings` and @seedgo one 343-char `summary`. +- **This gate never measures the writes that cause it.** @hooks' `edit_gate` says so in its own + refusal text: caps are measured on the Edit/Write lane only. A write made from the shell reaches + their handler — the project fence runs there — but never reaches the cap check, and @baud drifted + to 2529/300 for a week through that gap. An entry the gate never measured cannot be caught by + refusing the *next* write. + +So the narrowing put drift detection on the one component structurally blind to how drift arrives — +and charged rollover for it. **Rollover's write is always a shrink**: it removes the tail and hands +back a smaller document, and it may not edit the entry in the head it is being refused for. On +2026-08-30 that ran as an identical failure every 20 minutes for three hours against @seedgo's file, +until @trigger escalated four repeat signatures. The file could not get smaller because it was too +big. + +What the narrowing was **right** about is the silence: the old clause skipped a carried entry without +a word. Carried debt is now reported on every write, naming the branch, the entry and the numbers — +and drift detection sits where it belongs, in the lane that reads disk: -"Unchanged and over cap passes untouched" was written for a fleet full of legacy drift: without it a -maintenance write — a rollover, a frame re-render — would be refused whole over an entry it was not -touching, and the branch's memories would stop rolling. The push has since cured that drift, so for -the three archivable containers the clause protected nothing real and hid everything new: a fresh -over-cap `sessions` entry written straight to disk read as "already there" on the next write and never -surfaced. In `sessions`, `key_learnings` and `observations` an over-cap entry is now a violation -whether or not the current write created it. +``` +drone @memory lint # scans every branch's entries, read-only, owes nothing to write order +``` -`todos` keep the exemption, and only todos — `entry_limits.RESHAPE_ONLY_SECTIONS`, the single list the -write gate and the push's prune lane share (`trinity_push` re-exports it) rather than each restating -it. A non-canonical todo can sit in a branch indefinitely BY DESIGN, because the push is forbidden to -archive open work and only that branch's own agent can cure it; refusing every write to such a file -would brick its rollover, preserving the debt by destroying the lane that preserves everything else. -The exemption covers what is **already on disk** — a newly written over-cap todo is refused like -anything else. +`todos` are no longer a special case here — the rule is one rule. `RESHAPE_ONLY_SECTIONS` remains +`("todos",)` for the question it actually answers: the containers **no machine may prune**. The push +re-exports it for its prune lane and @hooks reads it for the missing-canonical-field refusal, one +list so the two cannot disagree within a release. + +Touch an entry and you own it: the exemption covers byte-identical text only, so editing a fat entry +into a slightly less fat one is authorship and is refused. Identity in a list is the **text**, never +the index — a prepend shifts every entry down, and an index-keyed diff would call the whole file +newly authored on exactly the write that authored nothing. + +**The lane that broke could not see what broke it.** With the rule fixed, restoring the defect as a +mutant killed exactly three tests — all at the *writer*, in `test_changed_entries.py`. Not one +rollover test died, because every extractor test mocks `memory_files` away: correct for testing +extraction logic, blind to the component that actually refused. @hooks found the mirror image in +their own tree the same evening — a mutant making their checker return nothing left all 115 of their +end-to-end tests green, because the union ran @memory's real diff. When two halves overlap, a suite +that cannot tell them apart proves neither. `TestRolloverSurvivesCarriedDebt` mocks nothing below the +extractor and asserts on **what is written to disk**, because "success" is exactly what the lane +reported for three hours while the file never changed. The mutant now kills six. ### keep 15 now keeps 15 @@ -572,8 +647,9 @@ itself fails, and a second destination fails after the first succeeded. Absent a Shape **and** size, because they are two different scan groups in the standard and an entry can pass one while failing the other. A perfectly-shaped 315-char session summary under a 300 cap is -canonical to look at and still leaves its branch short of 100 — and until 2026-08-27 it was exactly -the entry @hooks' `edit_gate` grandfathered, that clause now covering `todos` only. Caps come from +canonical to look at and still leaves its branch short of 100 — and it is exactly the entry @hooks' +`edit_gate` grandfathers when the write did not author it (refused between 2026-08-27 and 08-30, when +that refusal was found to deadlock rollover). Caps come from `entry_limits.resolve_entry_types`, the same resolver the write gate and the tab renderer use, so the push cannot prune on a number the gate does not enforce. @@ -834,7 +910,7 @@ enforcement that does not happen. `auto_compact_cap` appears only where one is s ## Quality - **Tests:** 1222 passed, 0 failures, 5 skipped, 21.8s (re-measured 2026-08-27 from the branch dir after the marker-7 build, which archived two test files with the retired lane and added `tests/test_marker7_memory_lane.py` and `tests/test_templates_lane.py`; the repo-root invocation behind the earlier 1201 reading was not repeated here). The push added 69 of those in `tests/test_trinity_push.py`, and 10 mutations were run against the lane — all 10 bite, each on its own tests. The first pass had one survivor (`_verify_ingestion` ignoring absent vectors, caught anyway by the content comparison that follows); rather than accept a provably-equivalent survivor, two tests were added pinning that an ABSENT vector and a CORRUPTED one produce different refusal sentences, and the mutation now bites. The 5 skips are the parked symbolic-fragments tier and its embedder — see `tests/parked/symbolic_20260814/` — and each names its reason in the skip message. A sixth skip appears on a fresh clone: the health test that reads this branch's real `.trinity/` files, which are gitignored (`tests/test_health.py:404`, "no live .trinity files in this checkout"). - *Two different numbers, deliberately:* `grep def test_` over `tests/*.py` finds **1323 test functions** on disk. 237 of those live in 5 modules that call `pytest.skip(allow_module_level=True)` at import, so pytest never collects them individually (they surface as the 5 skips). The rest expand through `@pytest.mark.parametrize` into **1222** collected cases, and all 1222 pass. Both numbers are true and neither substitutes for the other — seedgo's `readme` rule counts the 1323 on disk, a green board counts the 1222 that execute. + *Two different numbers, deliberately:* `grep def test_` over `tests/*.py` finds **1471 test functions** on disk. Some of those live in 5 modules that call `pytest.skip(allow_module_level=True)` at import, so pytest never collects them individually (they surface as the 5 skips). The rest expand through `@pytest.mark.parametrize` into **1428** collected cases, and all 1428 pass. Both numbers are true and neither substitutes for the other — seedgo's `readme` rule counts the 1471 on disk, a green board counts the 1428 that execute. - **Seedgo:** 100% across every rule including the `trinity` standard, 0 type errors — re-run `--full` 2026-08-27 after the marker 7 build. Two findings that build itself introduced were fixed, not bypassed: extracting `_handle_rollover_verb()` out of `rollover.handle_command()` moved the no-args gate behind a delegation (`introspection` 85%, and the checker was right — the entry seam should say for itself that a bare `rollover` introspects), and adding the renamed verb to the top-level `elif` chain pushed it to depth 5 (`deep_nesting`); the chain is now flat `if`/`return`, one arm per command. The four findings the first audit raised on the new files were fixed rather than bypassed: report rendering moved out of the module into `handlers/templates/push_report.py` (modules do no direct file I/O), `json_handler` logging added to both new handlers, and the `unused_function` hit on `is_canonical()` was cleared by giving it a real caller — the guard that measures the push's own session note against the same gate everything else was pruned against. The `--json` lane added exactly one rule (`json_flag.py` / `json_structure`), a verbatim mirror of the `help_flags.py` rule for its sibling predicate. The `cli` bypass it first appeared to need was **not** taken: `console.print(payload, markup=False, soft_wrap=True, highlight=False)` emits byte-exact JSON through the shared console, so no Rich bypass is required to serve a machine. - **Bypass registry:** **114** rules in `.seedgo/bypass.json` (`last_updated: 2026-08-16`). The old claim here — "113 rules, all pointing at files that exist", verified 2026-08-13 — is **stale on both counts**: re-measured 2026-08-25, **37 of the 114 point at 10 files that are no longer in the tree**, all of them parked on 08-14 / 08-18 (`symbolic/*.py`, `vector/embedder.py`, `storage/chroma.py`, `search/vector_search.py`, `learnings/manager.py`). One duplicate `(file, standard)` pair as well. The rules are inert — a bypass for an absent file suppresses nothing — but the registry is now a record of a tree that stopped existing. Cleanup is an open item, not fixed tonight. diff --git a/src/aipass/memory/apps/handlers/__init__.py b/src/aipass/memory/apps/handlers/__init__.py index 170c6e455..bb49e5b02 100644 --- a/src/aipass/memory/apps/handlers/__init__.py +++ b/src/aipass/memory/apps/handlers/__init__.py @@ -1,6 +1,7 @@ """Memory handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "aipass.memory" @@ -15,28 +16,63 @@ def _find_real_caller(): - Python's importlib internals - Frozen modules + NOT ``inspect.stack()``, and that is the whole point of this function's + shape. ``inspect.stack()`` builds a FrameInfo for every frame, and building + one resolves the frame's source file: ``getsourcefile`` -> ``getmodule`` -> + ``os.path.realpath``. On Windows ``ntpath.realpath`` reads the working + directory, so in a process whose cwd is gone this raised + ``FileNotFoundError`` INSIDE ``inspect.stack()`` — before any of the + skip-the-pseudo-file care below could run. Found on the Windows CI leg + 2026-08-31; the whole traceback sat above line 20, in the stdlib. + + Reading ``frame.f_code.co_filename`` off a raw frame walk asks the + filesystem nothing. It is also markedly cheaper at import time, which this + runs at, and it makes the existing guards reachable rather than decorative. + Returns tuple: (file_path, import_line) or (None, None) """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) + # NOT resolve(). Windows' ntpath.realpath reads the working directory + # UNCONDITIONALLY — even for an absolute path — so this line was the next + # import-time crash behind inspect.stack() in a dead cwd, and fixing only + # the first one would have moved the traceback down two lines. __file__ has + # been absolute since 3.9, so there is nothing here for resolve() to do + # that is worth a filesystem call at import time. + this_file = str(Path(__file__)) + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back + continue - for frame_info in stack: - filename = frame_info.filename + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename # Skip this file - if this_file in str(Path(filename).resolve()): + if this_file in resolved: + frame = frame.f_back continue - # Skip Python internals - if filename.startswith("<") or "importlib" in filename: - continue - - # Found a real file - try to get the import line + # Found a real file - try to get the import line. Only for the ONE + # frame being returned, and defensively: reading a source line touches + # the filesystem, and a diagnostic string is never worth the import it + # would take down. import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + try: + import_line = linecache.getline(resolved, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -64,19 +100,27 @@ def _guard_branch_access(): import os if os.environ.get("AIPASS_DEBUG_GUARD"): - import sys - + # sys is imported at module level now (the frame walk needs it), and a + # local import here would shadow it into "possibly unbound" for the + # walk below. print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) if caller_file is None: # Can't determine caller from real files # Check if we're being run from command line (external) - # by looking at the raw stack for or - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): + # by looking at the raw stack for or . + # A raw frame walk for the same reason _find_real_caller uses one: + # inspect.stack() resolves every frame's source file and reads the + # working directory doing it, which crashes this import on Windows in + # the very dead-cwd world this guard has to survive. Both outcomes here + # are `return`, so a crash would be the only thing this branch could + # ever contribute — it is pure diagnosis, and it must not be load-bearing. + frame = sys._getframe(1) + while frame is not None: + if frame.f_code.co_filename in ("", ""): return # Allow command-line Python through + frame = frame.f_back return # Allow if truly can't determine # Check if caller is from our branch diff --git a/src/aipass/memory/apps/handlers/archive/indexer.py b/src/aipass/memory/apps/handlers/archive/indexer.py index c09fec90c..a7852af38 100644 --- a/src/aipass/memory/apps/handlers/archive/indexer.py +++ b/src/aipass/memory/apps/handlers/archive/indexer.py @@ -25,11 +25,12 @@ from aipass.prax.apps.modules.logger import get_system_logger from aipass.memory.apps.handlers.json import json_handler +from aipass.memory.apps.handlers.repo_root import module_file logger = get_system_logger() # Paths resolved relative to handler location -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] CODE_ARCHIVE_PATH = _MEMORY_ROOT / "code_archive" INDEX_PATH = CODE_ARCHIVE_PATH / "index.json" diff --git a/src/aipass/memory/apps/handlers/central_writer.py b/src/aipass/memory/apps/handlers/central_writer.py index cd9bedf0d..cdf2f3dda 100644 --- a/src/aipass/memory/apps/handlers/central_writer.py +++ b/src/aipass/memory/apps/handlers/central_writer.py @@ -22,8 +22,10 @@ from datetime import datetime from typing import Dict, Any +from aipass.memory.apps.handlers import repo_root from aipass.prax.apps.modules.logger import get_system_logger from aipass.memory.apps.handlers.json import json_handler +from aipass.memory.apps.handlers.repo_root import module_file logger = get_system_logger() @@ -34,16 +36,21 @@ # CONSTANTS # ============================================================================= -_MEMORY_ROOT = Path(__file__).resolve().parents[2] +_MEMORY_ROOT = module_file(__file__).parents[2] def _find_repo_root() -> Path: - """Walk up from this file to find repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Repo root for this lane — resolved by ``handlers/repo_root.py``. + + Kept as a local name because callers and tests patch it here. The body is a + delegation on purpose: this function used to be one of ten byte-identical + copies, so the first cure landed on one file and CI went red on the next. + + Returns: + The directory holding AIPASS_REGISTRY.json, or the source tree. Never + the process working directory. + """ + return repo_root.find_repo_root(caller="central_writer") CENTRAL_FILE = _find_repo_root() / ".ai_central" / "MEMORY.central.json" diff --git a/src/aipass/memory/apps/handlers/intake/auto_process.py b/src/aipass/memory/apps/handlers/intake/auto_process.py index 75cb715b1..42f42e1dc 100644 --- a/src/aipass/memory/apps/handlers/intake/auto_process.py +++ b/src/aipass/memory/apps/handlers/intake/auto_process.py @@ -46,8 +46,9 @@ from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler, config_loader +from aipass.memory.apps.handlers.repo_root import module_file -_MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent.parent +_MEMORY_ROOT = module_file(__file__).parent.parent.parent.parent # Single-flight lock. Two sessions can start seconds apart, and two concurrent # rollovers would write the same .trinity files. Staleness is time-based rather @@ -172,7 +173,7 @@ def spawn_background() -> Dict[str, Any]: kwargs["start_new_session"] = True try: - child = subprocess.Popen([sys.executable, str(Path(__file__).resolve())], **kwargs) + child = subprocess.Popen([sys.executable, str(module_file(__file__))], **kwargs) except Exception as e: logger.error(f"[auto_process] Spawn failed: {e}") json_handler.log_operation("spawn_background", {"success": False, "error": str(e)}) diff --git a/src/aipass/memory/apps/handlers/intake/plans_processor.py b/src/aipass/memory/apps/handlers/intake/plans_processor.py index c3417c800..4f7cdaf59 100644 --- a/src/aipass/memory/apps/handlers/intake/plans_processor.py +++ b/src/aipass/memory/apps/handlers/intake/plans_processor.py @@ -18,7 +18,9 @@ Uses subprocess pattern for ML operations (memory venv isolation). """ +import hashlib import json +import re import os import subprocess import sys @@ -26,27 +28,34 @@ from datetime import datetime from typing import Dict, Any, List +from aipass.memory.apps.handlers import repo_root from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler from aipass.memory.apps.handlers.json import config_loader +from aipass.memory.apps.handlers.repo_root import module_file # Subprocess scripts -_HANDLERS_DIR = Path(__file__).resolve().parent.parent +_HANDLERS_DIR = module_file(__file__).parent.parent EMBED_SUBPROCESS_SCRIPT = _HANDLERS_DIR / "vector" / "embed_subprocess.py" CHROMA_SUBPROCESS_SCRIPT = _HANDLERS_DIR / "storage" / "chroma_subprocess.py" # Memory venv python -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] _MEMORY_VENV_PYTHON = _MEMORY_ROOT / ".venv" / "bin" / "python" def _find_repo_root() -> Path: - """Walk up from this file to find repo root.""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Repo root for this lane — resolved by ``handlers/repo_root.py``. + + Kept as a local name because callers and tests patch it here. The body is a + delegation on purpose: this function used to be one of ten byte-identical + copies, so the first cure landed on one file and CI went red on the next. + + Returns: + The directory holding AIPASS_REGISTRY.json, or the source tree. Never + the process working directory. + """ + return repo_root.find_repo_root(caller="plans_processor") def _get_memory_python() -> str: @@ -72,6 +81,42 @@ def _get_memory_python() -> str: # ============================================================================= +# A line that is nothing but a bracketed prompt: "[What do you want to achieve?]" +# or "". Anchored at both ends, so a markdown link like +# "[the audit](./audit.md)" -- which opens with a bracket and is real content -- +# does not match. +_PLACEHOLDER_LINE = re.compile(r"^\s*[\[<][^\n]*[\]>]\s*$") + +# A markdown horizontal rule. Structure, not content: the two most-repeated +# unfilled sections in the live collection both end in one, and treating it as +# content would have made this filter reach 2% instead of 5.4%. +_HORIZONTAL_RULE = re.compile(r"^\s*([-*_])\1{2,}\s*$") + + +def _is_placeholder_only(chunk_text: str) -> bool: + """True when a section's body is ONLY the template prompt nobody filled in. + + Vectorizing an unfilled section stores a question the TEMPLATE asked, + attributed to a plan that never answered it — 452 of 8,433 vectors in + flow_plans (5.4%), measured 2026-08-30 on @flow's proposal. + + Deliberately narrow. @flow's plan-level version of this idea + (``is_template_content``) was retired after it false-positived on + real-but-minimal FPLANs and destroyed the file, the registry row and the + archive together. Here the unit is a chunk, so the worst case is a dropped + empty section rather than a lost plan — and the rule fires only when EVERY + content line is bracketed. One line of real prose keeps the whole section. + + A body with no content lines at all is NOT a placeholder: that is absence, + which the length gate already handles, and saying otherwise would make this + function's own name wrong about what it found. + """ + lines = chunk_text.split("\n") + body = lines[1:] if lines and lines[0].lstrip().startswith("#") else lines + content = [line for line in body if line.strip() and not _HORIZONTAL_RULE.match(line)] + return bool(content) and all(_PLACEHOLDER_LINE.match(line) for line in content) + + def _chunk_plan_text(text: str, filename: str) -> List[Dict[str, str]]: """ Chunk plan text into sections for vectorization. @@ -134,7 +179,14 @@ def _chunk_plan_text(text: str, filename: str) -> List[Dict[str, str]]: else: final_chunks.append(chunk) - return final_chunks + # Filtered HERE, at the one exit, rather than at each of the four places a + # chunk is appended above -- a rule applied at three of four sites is the + # failure this branch spent the day fixing elsewhere. + kept = [c for c in final_chunks if not _is_placeholder_only(c["text"])] + dropped = len(final_chunks) - len(kept) + if dropped: + logger.info(f"[plans] {filename}: skipped {dropped} unfilled template section(s)") + return kept # ============================================================================= @@ -142,8 +194,12 @@ def _chunk_plan_text(text: str, filename: str) -> List[Dict[str, str]]: # ============================================================================= -def _load_manifest() -> Dict[str, str]: - """Load processed files manifest.""" +def _load_manifest() -> Dict[str, Any]: + """Load processed files manifest. + + Values are either the content-keyed row this module writes now, or the bare + ISO string written before 2026-08-30 -- see :func:`_recorded`. + """ if _PROCESSED_MANIFEST.exists(): try: return json.loads(_PROCESSED_MANIFEST.read_text(encoding="utf-8")) @@ -153,12 +209,70 @@ def _load_manifest() -> Dict[str, str]: return {} -def _save_manifest(manifest: Dict[str, str]) -> None: +def _save_manifest(manifest: Dict[str, Any]) -> None: """Save processed files manifest.""" _PROCESSED_MANIFEST.parent.mkdir(parents=True, exist_ok=True) _PROCESSED_MANIFEST.write_text(json.dumps(manifest, indent=2), encoding="utf-8") +def _content_hash(text: str) -> str: + """The plan's content, as one comparable value.""" + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _manifest_entry(text: str) -> Dict[str, str]: + """A row that records WHAT was processed, not merely that something was.""" + return {"processed_at": datetime.now().isoformat(), "content_sha256": _content_hash(text)} + + +def _recorded(entry: Any) -> tuple[str | None, str | None]: + """``(processed_at, content_sha256)`` from either manifest shape. + + Rows written before 2026-08-30 are a bare ISO string — processed, content + unrecorded. Both shapes are read here so the file needs no migration pass + and no version field; a legacy row upgrades itself the next time it is seen. + """ + if isinstance(entry, dict): + stamp = entry.get("processed_at") + digest = entry.get("content_sha256") + return (stamp if isinstance(stamp, str) else None, digest if isinstance(digest, str) else None) + if isinstance(entry, str): + return entry, None + return None, None + + +def _is_stale(plan_file: Path, entry: Any, text: str) -> bool: + """Does this file need processing, given what the manifest remembers? + + Keyed on CONTENT, not on the name alone. @flow found the failure the name + key caused: `restore` puts a plan file back but nothing removes its manifest + row, so when that plan is genuinely closed later its final content is never + vectorized and the store keeps only its pre-restore text. Three live cases. + Content keying is self-healing and needs nothing from the restoring lane — + it also covers a plan simply edited after close, which a restore callback + would still have missed. + """ + stamp, digest = _recorded(entry) + if digest is not None: + return digest != _content_hash(text) + + # Legacy row: no hash, so "unchanged" is a belief rather than a fact. The + # file's own mtime is the only evidence available, and it answers the case + # that matters -- a restore WRITES the file, long after the row was recorded. + # Measured 2026-08-30 before choosing this: 491 rows, 488 files present, ZERO + # of them modified after processing. So the backfill below cannot silently + # skip a change that already happened; this guard covers one arriving later. + if stamp: + try: + if datetime.fromtimestamp(plan_file.stat().st_mtime) > datetime.fromisoformat(stamp): + logger.info(f"[plans] {plan_file.name} is newer than its manifest row — re-processing") + return True + except (ValueError, OSError) as exc: + logger.warning(f"[plans] Cannot compare {plan_file.name} to its manifest row ({exc}) — re-processing") + return True + return False + + # ============================================================================= # SUBPROCESS WRAPPERS # ============================================================================= @@ -247,7 +361,34 @@ def process_plans() -> Dict[str, Any]: return {"success": True, "files_processed": 0, "total_chunks": 0} manifest = _load_manifest() - unprocessed = [f for f in files if f.name not in manifest] + + # Read each file ONCE here: the same text decides staleness and, for a file + # that turns out to be current, backfills the hash its legacy row never had. + unprocessed = [] + backfilled = 0 + for plan_file in files: + if plan_file.name not in manifest: + unprocessed.append(plan_file) + continue + try: + text = plan_file.read_text(encoding="utf-8") + except OSError as exc: + logger.warning(f"[plans] Cannot read {plan_file.name} to check freshness ({exc}) — re-processing") + unprocessed.append(plan_file) + continue + if _is_stale(plan_file, manifest[plan_file.name], text): + unprocessed.append(plan_file) + elif _recorded(manifest[plan_file.name])[1] is None: + # Current content, legacy row. Record what it was always missing + # rather than paying to embed 488 plans nothing suggests are stale. + manifest[plan_file.name] = { + "processed_at": _recorded(manifest[plan_file.name])[0] or datetime.now().isoformat(), + "content_sha256": _content_hash(text), + } + backfilled += 1 + if backfilled: + _save_manifest(manifest) + logger.info(f"[plans] Recorded a content hash for {backfilled} legacy manifest row(s)") if not unprocessed: return {"success": True, "files_processed": 0, "total_chunks": 0, "reason": "all files already processed"} @@ -268,7 +409,7 @@ def process_plans() -> Dict[str, Any]: chunks = _chunk_plan_text(text, plan_file.name) if not chunks: - manifest[plan_file.name] = datetime.now().isoformat() + manifest[plan_file.name] = _manifest_entry(text) _save_manifest(manifest) continue @@ -301,7 +442,7 @@ def process_plans() -> Dict[str, Any]: errors.append(f"{plan_file.name}: store error: {store_result.get('error')}") continue - manifest[plan_file.name] = datetime.now().isoformat() + manifest[plan_file.name] = _manifest_entry(text) _save_manifest(manifest) files_processed += 1 total_chunks += len(texts) diff --git a/src/aipass/memory/apps/handlers/intake/pool_processor.py b/src/aipass/memory/apps/handlers/intake/pool_processor.py index c8885d1a0..bb1243811 100644 --- a/src/aipass/memory/apps/handlers/intake/pool_processor.py +++ b/src/aipass/memory/apps/handlers/intake/pool_processor.py @@ -28,9 +28,10 @@ from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler from aipass.memory.apps.handlers.json import config_loader +from aipass.memory.apps.handlers.repo_root import module_file # Paths -_MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent.parent # handlers/intake/ → handlers/ → apps/ → memory/ +_MEMORY_ROOT = module_file(__file__).parent.parent.parent.parent # handlers/intake/ → handlers/ → apps/ → memory/ MEMORY_POOL_PATH = _MEMORY_ROOT / "memory_pool" CHROMA_PATH = _MEMORY_ROOT / ".chroma" diff --git a/src/aipass/memory/apps/handlers/json/config_loader.py b/src/aipass/memory/apps/handlers/json/config_loader.py index 98bd50c7e..b5a3ef66c 100644 --- a/src/aipass/memory/apps/handlers/json/config_loader.py +++ b/src/aipass/memory/apps/handlers/json/config_loader.py @@ -39,8 +39,9 @@ from aipass.memory.apps.handlers.json import json_handler from aipass.prax import logger +from aipass.memory.apps.handlers.repo_root import module_file -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] _CONFIG_PATH = _MEMORY_ROOT / "memory_json" / "custom_config" / "memory.config.json" DEFAULT_CONFIG: dict[str, Any] = { @@ -293,19 +294,31 @@ def section(name: str) -> dict[str, Any]: def _find_repo_root() -> Path: - """Walk up from this file to find repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Repo root for this lane — resolved by ``handlers/repo_root.py``. + + IMPORTED INSIDE THE FUNCTION, not at module level. ``handlers/json/__init__`` + imports this module, and ``repo_root`` imports ``handlers.json`` for its + audit line, so a module-level edge here would be a cycle that only appears + in whichever import order CI happens to take. + + Returns: + The directory holding AIPASS_REGISTRY.json, or the source tree. Never + the process working directory. + """ + from aipass.memory.apps.handlers import repo_root + + return repo_root.find_repo_root(caller="config_loader") def materialize_per_branch() -> dict[str, Any]: """Build per_branch from AIPASS_REGISTRY.json, seeded from rollover.defaults.""" repo_root = _find_repo_root() registry_path = repo_root / "AIPASS_REGISTRY.json" - if not registry_path.exists(): + from aipass.memory.apps.handlers import repo_root + + # exists_exactly, not exists(): see handlers/repo_root.py. Function-local + # import for the same cycle reason _find_repo_root uses one. + if not repo_root.exists_exactly(registry_path): logger.warning("[config_loader] AIPASS_REGISTRY.json not found") return {} diff --git a/src/aipass/memory/apps/handlers/json/entry_limits.py b/src/aipass/memory/apps/handlers/json/entry_limits.py index f015ef359..c30a3dcf1 100644 --- a/src/aipass/memory/apps/handlers/json/entry_limits.py +++ b/src/aipass/memory/apps/handlers/json/entry_limits.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: entry_limits.py # Description: Entry limits config reader, validator, and diff helper for memory files -# Version: 1.5.0 +# Version: 1.8.0 # Created: 2026-06-13 -# Modified: 2026-06-13 +# Modified: 2026-08-31 # ============================================= """ @@ -20,37 +20,67 @@ before/after file dicts and returns only NEW or CHANGED entries that exceed their character cap. -THE GRANDFATHER CLAUSE, NARROWED 2026-08-27 -------------------------------------------- -"Unchanged and over cap passes untouched" was written for a fleet full of -legacy drift: without it a maintenance write — a rollover, a frame -re-render — would be refused whole because of an entry it was not touching, -and the branch's memories would stop rolling. The trinity push has since -cured that drift fleet-wide, so for the three ARCHIVABLE containers the -clause now protects nothing real and hides everything new: a fresh over-cap -session written straight to disk reads as "already there" on the next write -and never surfaces. - -``todos`` keep the exemption, and only todos. A non-canonical todo can sit -in a branch indefinitely BY DESIGN — the push is forbidden to archive open -work (1.1.0), so nothing but that branch's own agent can ever cure it. -Refusing every write to such a file would brick its rollover, which is -slow-motion data loss: the debt would be preserved by destroying the lane -that preserves everything else. The gate and the push share ONE -``RESHAPE_ONLY_SECTIONS`` rather than each restating it, because two lists of -"the containers we may not prune" would disagree within a release. - -The exemption covers what is ALREADY ON DISK. A newly written over-cap todo -is refused like anything else. - -The constant lives HERE rather than beside the push's prune lane only because -``trinity_push`` already imports this module: defining it there and importing -it back would close a cycle (entry_limits -> trinity_push -> memory_files -> -entry_limits). The push re-exports it, so both lanes still read one list. +JUDGED BY WHAT IT AUTHORS, NOT BY WHAT IT CARRIES (2026-08-30) +------------------------------------------------------------- +An entry byte-identical to one already on disk was not written by this write, +and a write cannot be refused for text it did not author. + +That clause was narrowed to ``todos`` on 2026-08-27 on the reasoning that the +trinity push had cured drift fleet-wide, so "unchanged and over cap passes" +now hid new drift rather than protecting old. Three hours of identical +rollover errors proved both halves wrong about the world: + + * Drift RECURS. @ai_mail carried three over-cap key_learnings the same + evening the deadlock was reported; @seedgo carried one 343-char summary. + * This gate is structurally BLIND to how drift arrives. @hooks' edit_gate + says so in its own refusal text: caps are measured on the Edit/Write lane + only. A write made from the shell reaches their handler (the project + fence runs there) but never reaches the cap check, and @baud drifted to + 2529/300 for a week through that gap. An entry the gate never MEASURED + cannot be caught by refusing the NEXT write. + +So the narrowing put the detection job on the one component that cannot see +the drift arrive, and charged rollover for it: the extractor removed a tail, +wrote the document back, and was refused whole for an entry in the head it is +not allowed to touch. The archiver loses that deadlock every time — the file +cannot get smaller because it is too big. + +Detection belongs to the lane that READS DISK: ``drone @memory lint`` scans +every branch's entries on demand, read-only, and owes nothing to write order. + +What the narrowing was RIGHT about is the silence. The old clause skipped a +carried over-cap entry without a word. It is now reported instead: the same +diff yields two labels, and each consumer sets its own policy from one +measurement. + + ``classify_entries()`` → both halves from ONE traversal: + ``["authored"]`` — what this write wrote. Refuse these. + ``["carried"]`` — what it carries from disk. Report these. + ``["near"]`` — authored and CLOSE to the cap. Report these too. + +THE NEAR-CAP LINE (1.7.0, 2026-08-31), asked for by @ai_mail with the best +argument available: they wrote over the cap FOUR HOURS after being burned by it, +knowing the number, with it in front of them. Their words, and the reason this +is not a knowledge problem: "nothing in the act of writing shows you the limit — +the only instrument is downstream." A refusal teaches you at the moment it is +too late to matter; a near-cap line arrives while there is still room to act. + +Only AUTHORED entries are reported near. A carried near-cap entry is not this +write's doing, and warning about it on every write is how a channel becomes +noise nobody reads — the same discriminator that decides refusals, applied to +the softer signal for the same reason. + + ``changed_entries()`` is the authored half alone, kept because @hooks' + edit_gate calls it by that name — the published contract, unchanged in + shape. + +The labels PARTITION the over-cap set; no entry wears both. Touch an entry +and you own it — the exemption covers byte-identical text only, so editing a +fat entry into a slightly less fat one is authorship and is refused. Usage: from aipass.memory.apps.handlers.json.entry_limits import ( - load_entry_limits, check_entry, changed_entries, + load_entry_limits, check_entry, changed_entries, classify_entries, ) limits = load_entry_limits("devpulse") @@ -59,26 +89,84 @@ violations = changed_entries(before_dict, after_dict, limits) # => [{"entry_type", "container", "key", "length", "cap", "over_by"}, ...] + + split = classify_entries(before_dict, after_dict, limits) + # => {"authored": [...], "carried": [...]} — same six-key shape in both """ import copy -from pathlib import Path from typing import Any from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler from aipass.memory.apps.handlers.json import config_loader +from aipass.memory.apps.handlers.repo_root import module_file # Resolve paths relative to handler location (same pattern as memory_files.py) -_MEMORY_ROOT = Path(__file__).resolve().parents[3] - -# Containers where a non-canonical entry may legitimately persist, because no -# machine is allowed to remove it. Today: todos — open work is never archived, -# so only the branch's own agent can cure a drifted one. See the module -# docstring; ``trinity_push`` re-exports this as its prune-lane exemption. +_MEMORY_ROOT = module_file(__file__).parents[3] + +# Containers no machine may PRUNE. Today: todos — open work is never archived, +# so only the branch's own agent can cure a drifted one. +# +# This is no longer the cap-exemption discriminator. From 2026-08-30 every +# container is exempt from being refused for text it did not author, so a list +# of "containers we may not prune" answers a question the cap gate stopped +# asking. +# +# ONE consumer remains: ``trinity_push`` re-exports it for its prune lane. +# @hooks read it at call time until 2026-08-30, then deleted their seam the +# same evening — with the rule universal it suppressed nothing on their side, +# and their own reasoning is worth keeping: a rule that has stopped +# suppressing anything is indistinguishable from a load-bearing one. Should +# this fall to zero consumers it should go too, by that same argument. +# +# It lives HERE rather than beside the push only because ``trinity_push`` +# already imports this module: defining it there and importing it back would +# close a cycle (entry_limits -> trinity_push -> memory_files -> entry_limits). RESHAPE_ONLY_SECTIONS = ("todos",) +# How close to the cap earns a line. 0.9 puts a 200-char cap's warning at 180, +# which is roughly one more sentence of headroom. A ratio rather than a fixed +# margin so it scales with caps that differ by an order of magnitude across +# entry types. +# +# "NOT SO EARLY THAT MOST WRITES TRIP IT" IS WHAT I FIRST WROTE HERE, AND IT IS +# FALSE — measured 2026-08-31 across all 18 branches' .trinity files, 735 +# entries, at @ai_mail's request rather than on my own initiative. They saw it +# fire on 13 of their own 15 key_learnings and asked for the fleet number +# before accepting the threshold, which is the right order. +# +# entry_type n fires median length/cap +# sessions 294 65.0% 0.94 +# key_learnings 258 60.5% 0.92 +# todos 74 48.6% 0.90 +# observations 109 35.8% 0.87 +# TOTAL 735 57.4% +# +# And @ai_mail is not the outlier they assumed: at 46.4% they sit BELOW the +# fleet's 57.4%, twelfth of eighteen. The band is everyone's. +# +# THE THRESHOLD SWEEP HAS NO KNEE, which is the finding rather than the number: +# +# 0.90 -> 57.7% of entries (19 chars of headroom at a 200 cap) +# 0.95 -> 35.7% (10 chars) +# 0.97 -> 24.2% ( 6 chars) +# 0.99 -> 11.6% ( 2 chars) +# +# Every threshold quiet enough to read as signal leaves too little room to act +# on, which is the one thing this line exists to give. So the distribution is +# not telling us the warning is mistuned — it is telling us the CAP is tight, +# and people write to the target they are given. That is a fleet-policy +# question (whose caps these are is not mine to answer), and it is routed with +# these numbers rather than settled by quietly retuning a constant here. +# +# 0.9 STAYS in the meantime, on measured value rather than taste: it has caught +# @ai_mail three times and this branch four times in the two days it has +# existed, and it is one line per authored entry, not per write. +NEAR_CAP_RATIO = 0.9 + + def _deep_merge_entry_types( base: dict[str, Any], overrides: dict[str, Any], @@ -248,12 +336,31 @@ def check_entry(entry_type: str, text: Any, limits: dict[str, Any]) -> dict[str, cap = type_def.get("max_chars", 0) over_by = max(0, length - cap) + # The near-cap threshold is published as a CHARACTER COUNT rather than left + # for the caller to recompute from a ratio, for the same reason `over_by` is: + # a second implementation of the same arithmetic is a second chance for the + # warning and the refusal to disagree about one entry. + # + # `near_cap_ratio` on the type definition wins over the module default when + # it is present. @ai_mail's argument, and it is the right shape: one ratio + # across four containers whose median fill differs by seven points is one + # number doing four jobs. The knob now lives where `max_chars` lives, so + # whoever owns the caps owns this too — which is not me. + ratio = type_def.get("near_cap_ratio", NEAR_CAP_RATIO) + if not isinstance(ratio, int | float) or not 0 < ratio <= 1: + logger.warning( + f"[entry_limits] Ignoring near_cap_ratio {ratio!r} for '{entry_type}' — " + f"expected a number in (0, 1]; using {NEAR_CAP_RATIO}" + ) + ratio = NEAR_CAP_RATIO + return { "ok": length <= cap, "length": length, "cap": cap, "over_by": over_by, "entry_type": entry_type, + "near_at": cap * ratio, } @@ -328,6 +435,35 @@ def _found_type(value: Any, field: str) -> str: return type(value).__name__ +def is_near_cap(verdict: dict[str, Any]) -> bool: + """True when a PASSING verdict is close enough to its cap to be worth a word. + + Deliberately not a second measurement: it reads the numbers + :func:`check_entry` already produced, so the warning and the refusal can + never disagree about a length. + + A cap of 0 means "no cap known for this type", and there is nothing to be + near. Returning True there would put a line on every entry of every type + nobody has configured. + + Args: + verdict: A verdict dict from :func:`check_entry`. + + Returns: + True when the entry is within cap but at or above the ``near_at`` + threshold :func:`check_entry` published for it — which is + :data:`NEAR_CAP_RATIO` of the cap unless the entry type overrides it + with its own ``near_cap_ratio``. + """ + cap = verdict.get("cap", 0) + if not verdict.get("ok") or cap <= 0: + return False + # A verdict from before `near_at` existed still answers correctly rather + # than reading a missing key as "never near". + near_at = verdict.get("near_at", cap * NEAR_CAP_RATIO) + return verdict["length"] >= near_at + + def _violation( type_name: str, container: str, @@ -373,8 +509,8 @@ def _check_dict_container( before_container: Any, after_container: Any, limits: dict[str, Any], -) -> list[dict[str, Any]]: - """Check dict-shaped container for new/changed over-limit entries. +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + """Split a dict-shaped container's over-cap entries into authored / carried. Args: type_name: Entry type name (e.g. ``"key_learnings"``). @@ -385,24 +521,29 @@ def _check_dict_container( limits: The dict returned by :func:`load_entry_limits`. Returns: - List of violation dicts for over-cap entries. In a RESHAPE_ONLY - container, entries already on disk are skipped; everywhere else an - over-cap entry is a violation whether or not this write created it. + ``(authored, carried, near)``. An entry byte-identical to the one under + the same key on disk is CARRIED — this write did not write it. + Everything else over cap is AUTHORED. ``near`` holds the authored + entries that PASS but sit close to the cap. """ if not isinstance(after_container, dict): - return [] + return [], [], [] before_dict = before_container if isinstance(before_container, dict) else {} - hits: list[dict[str, Any]] = [] + authored: list[dict[str, Any]] = [] + carried: list[dict[str, Any]] = [] + near: list[dict[str, Any]] = [] - exempt = container in RESHAPE_ONLY_SECTIONS for key, after_value in after_container.items(): after_text = _extract_text(after_value, field) - if exempt and _is_unchanged(after_text, after_value, key in before_dict, before_dict.get(key), field): - continue # Already on disk in a container nothing may prune verdict = check_entry(type_name, after_text, limits) - if not verdict["ok"]: - hits.append(_violation(type_name, container, str(key), verdict, _found_type(after_value, field), field)) - return hits + on_disk = _is_unchanged(after_text, after_value, key in before_dict, before_dict.get(key), field) + if verdict["ok"]: + if is_near_cap(verdict) and not on_disk: + near.append(_violation(type_name, container, str(key), verdict)) + continue + hit = _violation(type_name, container, str(key), verdict, _found_type(after_value, field), field) + (carried if on_disk else authored).append(hit) + return authored, carried, near def _check_list_container( @@ -412,8 +553,12 @@ def _check_list_container( before_container: Any, after_container: Any, limits: dict[str, Any], -) -> list[dict[str, Any]]: - """Check list-shaped container for new/changed over-limit entries. +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + """Split a list-shaped container's over-cap entries into authored / carried. + + Identity is the TEXT, never the index. Rollover removes the tail and every + surviving entry shifts position — matching on index would call the whole + file newly authored on exactly the write that authored nothing. Args: type_name: Entry type name (e.g. ``"sessions"``). @@ -424,12 +569,10 @@ def _check_list_container( limits: The dict returned by :func:`load_entry_limits`. Returns: - List of violation dicts for over-cap entries. In a RESHAPE_ONLY - container, entries already on disk are skipped; everywhere else an - over-cap entry is a violation whether or not this write created it. + ``(authored, carried, near)``. See :func:`_check_dict_container`. """ if not isinstance(after_container, list): - return [] + return [], [], [] before_list = before_container if isinstance(before_container, list) else [] before_texts = {t for t in (_extract_text(item, field) for item in before_list) if t is not None} # Unmeasurable entries are identified by their RAW value, never by the @@ -437,38 +580,39 @@ def _check_list_container( # single legacy list-note could add ten more and every one would read as # "already on disk" — the fix would open the hole it came to close. before_unmeasurable = [item for item in before_list if _extract_text(item, field) is None] - hits: list[dict[str, Any]] = [] + authored: list[dict[str, Any]] = [] + carried: list[dict[str, Any]] = [] + near: list[dict[str, Any]] = [] - exempt = container in RESHAPE_ONLY_SECTIONS for idx, after_item in enumerate(after_container): after_text = _extract_text(after_item, field) - if exempt: - if after_text is None: - if after_item in before_unmeasurable: - continue # Already on disk, and nothing may prune it - elif after_text in before_texts: - continue # Already on disk, and nothing may prune it verdict = check_entry(type_name, after_text, limits) - if not verdict["ok"]: - hits.append(_violation(type_name, container, str(idx), verdict, _found_type(after_item, field), field)) - return hits + if after_text is None: + on_disk = after_item in before_unmeasurable + else: + on_disk = after_text in before_texts + if verdict["ok"]: + if is_near_cap(verdict) and not on_disk: + near.append(_violation(type_name, container, str(idx), verdict)) + continue + hit = _violation(type_name, container, str(idx), verdict, _found_type(after_item, field), field) + (carried if on_disk else authored).append(hit) + return authored, carried, near -def changed_entries( +def classify_entries( before: dict[str, Any], after: dict[str, Any], limits: dict[str, Any], -) -> list[dict[str, Any]]: - """Return over-limit entries that are NEW or CHANGED between *before* and *after*. +) -> dict[str, list[dict[str, Any]]]: + """Split every over-cap entry in *after* into what this write authored and what it carries. - This is a **pure function** — no I/O, no file reads, no side effects. + The ONE traversal. :func:`changed_entries` and :func:`carried_entries` are + selectors over this result, so the two labels cannot drift apart and no + entry can wear both — a caller wanting both (the write gate does: refuse + one, report the other) should call this once rather than measure twice. - In a ``RESHAPE_ONLY_SECTIONS`` container (todos) an entry already on disk - is skipped even when over cap, so a maintenance write is never blocked by - a debt no machine is allowed to prune. Everywhere else an over-cap entry - is reported whether or not this write created it — the fleet is canonical, - so "unchanged" no longer means "legacy", it means "written and not yet - caught". + This is a **pure function** — no I/O, no file reads, no side effects. Args: before: Parsed .trinity file dict (current on-disk content). @@ -476,21 +620,13 @@ def changed_entries( limits: The dict returned by :func:`load_entry_limits`. Returns: - List of violation dicts, each containing:: - - { - "entry_type": str, # e.g. "key_learnings" - "container": str, # e.g. "key_learnings" - "key": str, # dict key or list index (as str) - "length": int, # len(text) - "cap": int, # max_chars - "over_by": int, # length - cap - } - - Empty list when everything is within limits or unchanged. + ``{"authored": [...], "carried": [...], "near": [...]}`` — dicts in the + published six-key shape. """ entry_types = limits.get("entry_types", {}) - violations: list[dict[str, Any]] = [] + authored: list[dict[str, Any]] = [] + carried: list[dict[str, Any]] = [] + near: list[dict[str, Any]] = [] for type_name, type_def in entry_types.items(): container = type_def.get("container", "") @@ -504,12 +640,56 @@ def changed_entries( before_container = before.get(container) if kind == "dict": - violations.extend( - _check_dict_container(type_name, container, field, before_container, after_container, limits) - ) + checker = _check_dict_container elif kind == "list": - violations.extend( - _check_list_container(type_name, container, field, before_container, after_container, limits) - ) + checker = _check_list_container + else: + continue + + type_authored, type_carried, type_near = checker( + type_name, container, field, before_container, after_container, limits + ) + authored.extend(type_authored) + carried.extend(type_carried) + near.extend(type_near) + + return {"authored": authored, "carried": carried, "near": near} + + +def changed_entries( + before: dict[str, Any], + after: dict[str, Any], + limits: dict[str, Any], +) -> list[dict[str, Any]]: + """Return over-limit entries that are NEW or CHANGED between *before* and *after*. + + This is what the write AUTHORED, and the only thing a write may be refused + for. An entry byte-identical to one already on disk is not reported here — + it is ``classify_entries(...)["carried"]`` — because refusing a write for text + it did not write deadlocks the archiver: rollover's whole job is handing + back a SMALLER document, and it may not shrink an entry it is only moving + past. See the module docstring for the three hours of identical errors that + settled it. + + This is a **pure function** — no I/O, no file reads, no side effects. - return violations + Args: + before: Parsed .trinity file dict (current on-disk content). + after: Parsed .trinity file dict (proposed new content). + limits: The dict returned by :func:`load_entry_limits`. + + Returns: + List of violation dicts, each containing:: + + { + "entry_type": str, # e.g. "key_learnings" + "container": str, # e.g. "key_learnings" + "key": str, # dict key or list index (as str) + "length": int, # len(text) + "cap": int, # max_chars + "over_by": int, # length - cap + } + + Empty list when everything is within limits or already on disk. + """ + return classify_entries(before, after, limits)["authored"] diff --git a/src/aipass/memory/apps/handlers/json/json_handler.py b/src/aipass/memory/apps/handlers/json/json_handler.py index 9a9d15e8e..776353a27 100644 --- a/src/aipass/memory/apps/handlers/json/json_handler.py +++ b/src/aipass/memory/apps/handlers/json/json_handler.py @@ -12,11 +12,10 @@ All functions are re-exported for backward-compatible imports. """ -from pathlib import Path - from aipass.aipass.shared.json_handler import JsonHandler +from aipass.memory.apps.handlers.repo_root import module_file -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] _JSON_DIR = _MEMORY_ROOT / "memory_json" _handler = JsonHandler(json_dir=_JSON_DIR) diff --git a/src/aipass/memory/apps/handlers/json/memory_files.py b/src/aipass/memory/apps/handlers/json/memory_files.py index 941cf7b21..4d2a2e5c3 100644 --- a/src/aipass/memory/apps/handlers/json/memory_files.py +++ b/src/aipass/memory/apps/handlers/json/memory_files.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: memory_files.py # Description: Memory File Safe I/O Handler -# Version: 1.2.0 +# Version: 1.3.0 # Created: 2026-03-17 -# Modified: 2026-08-08 +# Modified: 2026-08-30 # ============================================= """ @@ -35,12 +35,13 @@ from aipass.prax.apps.modules.logger import get_system_logger from aipass.memory.apps.handlers.json import json_handler from aipass.memory.apps.handlers.json import config_loader -from aipass.memory.apps.handlers.json.entry_limits import load_entry_limits, changed_entries +from aipass.memory.apps.handlers.json.entry_limits import load_entry_limits, classify_entries +from aipass.memory.apps.handlers.repo_root import module_file logger = get_system_logger() # Resolve paths relative to handler location -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] _CONFIG_DIR = _MEMORY_ROOT / "config" _TEMPLATES_DIR = _MEMORY_ROOT / "apps" / "json_templates" @@ -66,9 +67,10 @@ def _validate_entry_limits( in :data:`_TRACKED_TRINITY_FILES`. For all other paths this function returns ``None`` immediately (no validation). - Unchanged entries (same text as on disk) are intentionally skipped so - that rollover and other maintenance writes are never blocked by - legacy over-limit entries. + A write is judged by what it AUTHORS. Entries byte-identical to what is + already on disk are reported as carried debt and never refused, so + rollover and other maintenance writes cannot be blocked by an entry they + are not allowed to shrink. Args: file_path: Target path for the write. @@ -113,7 +115,42 @@ def _validate_entry_limits( logger.warning(f"[entry_limits] Could not parse {file_path.name} for diff: {exc}") before = {} # Unparseable — treat as empty (all entries "new") - over = changed_entries(before, data, filtered_limits) + split = classify_entries(before, data, filtered_limits) + over = split["authored"] + + # --- Carried debt: report, never refuse ----------------------------------- + # An over-cap entry byte-identical to the one on disk was not written by + # this write, so refusing the write cannot cure it and only stops the write. + # For rollover that refusal is a deadlock — the document it hands back is + # the SMALLER one, and it may not shrink an entry it is merely moving past. + # Passing it in silence is the other half of the defect, and the half the + # 2026-08-27 narrowing was right about: the old clause skipped these + # without a word, so a fat entry that arrived through an ungated lane never + # surfaced here again. It gets a line now, naming the branch, the entry and + # the numbers — `drone @memory lint` remains the lane that reads disk. + for debt in split["carried"]: + logger.warning( + f"[entry_limits] CARRIED {branch} {file_path.name} " + f"{debt['container']}[{debt['key']}] " + f"{debt['length']}/{debt['cap']} (+{debt['over_by']} over) — " + f"not written by this write, not refused; only @{branch} can cure it" + ) + + # --- Near the cap: the line that arrives while there is still room -------- + # @ai_mail's ask, and their evidence was the argument: they wrote over the + # cap FOUR HOURS after being burned by it, knowing the number. "Nothing in + # the act of writing shows you the limit — the only instrument is + # downstream." A refusal teaches at the moment it is too late; this teaches + # one entry early. Authored only: warning about carried near-cap text on + # every write is how a channel becomes noise nobody reads. + for close in split["near"]: + logger.warning( + f"[entry_limits] NEAR {branch} {file_path.name} " + f"{close['container']}[{close['key']}] " + f"{close['length']}/{close['cap']} — " + f"{close['cap'] - close['length']} chars of headroom left; the next edit may be refused" + ) + if not over: return None @@ -294,7 +331,7 @@ def write_memory_file(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: # --- Entry-limits validation (rollover-safe) ------------------------------ # Only validate .trinity/ files that are tracked (local.json, observations.json). - # Unchanged legacy over-limit entries pass untouched so rollover is never blocked. + # Refuses what this write authored; reports what it carries. try: rejection = _validate_entry_limits(file_path, data) if rejection is not None: diff --git a/src/aipass/memory/apps/handlers/monitor/detector.py b/src/aipass/memory/apps/handlers/monitor/detector.py index db578824a..3a036b091 100644 --- a/src/aipass/memory/apps/handlers/monitor/detector.py +++ b/src/aipass/memory/apps/handlers/monitor/detector.py @@ -28,9 +28,11 @@ from typing import List, Dict, Any from dataclasses import dataclass +from aipass.memory.apps.handlers import repo_root from aipass.prax.apps.modules.logger import get_system_logger from aipass.memory.apps.handlers.json import json_handler from aipass.memory.apps.handlers.json import config_loader +from aipass.memory.apps.handlers.repo_root import module_file logger = get_system_logger() @@ -39,16 +41,21 @@ def _find_repo_root() -> Path: - """Walk up from this file to find repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Repo root for this lane — resolved by ``handlers/repo_root.py``. + + Kept as a local name because callers and tests patch it here. The body is a + delegation on purpose: this function used to be one of ten byte-identical + copies, so the first cure landed on one file and CI went red on the next. + + Returns: + The directory holding AIPASS_REGISTRY.json, or the source tree. Never + the process working directory. + """ + return repo_root.find_repo_root(caller="detector") _REPO_ROOT = _find_repo_root() -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] _KNOWN_REGISTRIES_PATH = _MEMORY_ROOT / "memory_json" / "known_registries.json" @@ -107,7 +114,16 @@ def _find_caller_registries() -> List[Path]: cwd_found: List[Path] = [] for parent in [caller_cwd] + list(caller_cwd.parents): - for reg in parent.glob("*_REGISTRY.json"): + # EXACT CASE, and this walk is the one that had most to lose by it. + # It runs from the CALLER'S directory -- an arbitrary repo -- and a + # folding filesystem serves any lowercase *_registry.json there: + # flow's plan counters, .spawn/.template_registry.json, bait in every + # branch. A match here is not merely read, it is persist_registry()'d + # into known_registries.json permanently, and the `break` below means a + # spurious nearer hit STOPS the walk before the real registry above it + # is ever seen. Refusing, admitting, and forgetting -- this one does all + # three. See repo_root.exactly_named. + for reg in repo_root.exactly_named(sorted(parent.glob("*_REGISTRY.json")), "_REGISTRY.json"): if reg.resolve() != aipass_registry: cwd_found.append(reg) if cwd_found: @@ -277,7 +293,7 @@ def _count_file_lines(file_path: Path) -> int: return 0 -_TEMPLATES_DIR = Path(__file__).resolve().parents[3] / "templates" +_TEMPLATES_DIR = module_file(__file__).parents[3] / "templates" _TEMPLATE_MAP = { "local": _TEMPLATES_DIR / "LOCAL.template.json", "observations": _TEMPLATES_DIR / "OBSERVATIONS.template.json", diff --git a/src/aipass/memory/apps/handlers/monitor/memory_watcher.py b/src/aipass/memory/apps/handlers/monitor/memory_watcher.py index b545d18e9..85c0eebd4 100644 --- a/src/aipass/memory/apps/handlers/monitor/memory_watcher.py +++ b/src/aipass/memory/apps/handlers/monitor/memory_watcher.py @@ -46,14 +46,17 @@ # Handler imports (relative within package — after conditional watchdog block) from aipass.memory.apps.handlers.tracking.line_counter import update_line_count # noqa: E402 from aipass.memory.apps.handlers.monitor.detector import check_single_file # noqa: E402 +from aipass.memory.apps.handlers import repo_root +from aipass.memory.apps.handlers.repo_root import exactly_named, exists_exactly # noqa: E402 from aipass.prax.apps.modules.logger import get_system_logger # noqa: E402 from aipass.memory.apps.handlers.json import json_handler # noqa: E402 from aipass.memory.apps.handlers.json import config_loader # noqa: E402 +from aipass.memory.apps.handlers.repo_root import module_file # noqa: E402 logger = get_system_logger() # Memory root resolved relative to handler location -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] # Global observer instance _observer: Any = None @@ -148,7 +151,10 @@ def check_and_rollover() -> Dict[str, Any]: if not trinity_dir.exists(): continue for pattern in ["local.json", "observations.json"]: - for memory_file in trinity_dir.glob(pattern): + # Not glob(): the pattern has no wildcard, and a folding filesystem + # would serve Local.json here. Rollover REWRITES what this matches, + # so a near-miss is damage rather than a miscount. + for memory_file in [trinity_dir / pattern] if exists_exactly(trinity_dir / pattern) else []: results["files_checked"] += 1 try: @@ -330,12 +336,17 @@ def _check_code_archive() -> Dict[str, Any]: def _find_repo_root() -> Path: - """Walk up from this file to find repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Repo root for this lane — resolved by ``handlers/repo_root.py``. + + Kept as a local name because callers and tests patch it here. The body is a + delegation on purpose: this function used to be one of ten byte-identical + copies, so the first cure landed on one file and CI went red on the next. + + Returns: + The directory holding AIPASS_REGISTRY.json, or the source tree. Never + the process working directory. + """ + return repo_root.find_repo_root(caller="memory_watcher") def _paths_from_registry(registry_path: Path, root: Path) -> list[Path]: @@ -400,7 +411,10 @@ def _add_from_registry(reg: Path) -> None: cwd_found: list[Path] = [] for parent in [caller_cwd] + list(caller_cwd.parents): - for reg in parent.glob("*_REGISTRY.json"): + # EXACT CASE -- detector's twin of this walk, and the consequence here + # is that a folded match's "branches" become paths this watcher ROLLS + # OVER, i.e. rewrites. See repo_root.exactly_named. + for reg in exactly_named(sorted(parent.glob("*_REGISTRY.json")), "_REGISTRY.json"): if reg.resolve() != aipass_registry: cwd_found.append(reg) if cwd_found: diff --git a/src/aipass/memory/apps/handlers/monitor/registry_scope.py b/src/aipass/memory/apps/handlers/monitor/registry_scope.py index b264e1e7c..2237787e9 100644 --- a/src/aipass/memory/apps/handlers/monitor/registry_scope.py +++ b/src/aipass/memory/apps/handlers/monitor/registry_scope.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: registry_scope.py -# Description: The one definition of "the fleet" — core citizens plus passport-declared residents -# Version: 2.0.0 +# Description: The one definition of "the fleet" — core, passport-declared residents, declared-root externals +# Version: 4.1.0 # Created: 2026-08-27 -# Modified: 2026-08-28 +# Modified: 2026-08-30 # ============================================= """Fleet Scope @@ -78,6 +78,7 @@ from typing import Any from aipass.prax import logger +from aipass.memory.apps.handlers import repo_root from aipass.memory.apps.handlers.json import json_handler CORE_REGISTRY = "AIPASS_REGISTRY.json" @@ -92,14 +93,72 @@ RESIDENCY_CORE = "core" RESIDENCY_RESIDENT = "resident" +# The third TIER, which is deliberately not a third passport value. FPLAN-0460 +# phase 2 retired the schema migration as a precondition: external membership is +# PRESENCE (a passport exists), not DECLARATION (a passport says a word). None of +# the six live external citizens carries a residency field, so gating on one +# would have shipped a feature nobody could reach. The label is ours to apply, +# not theirs to claim. +RESIDENCY_EXTERNAL = "external" + +# The machine-scope anchor: AIPass home declares which repo roots participate. +# Beside AIPASS_REGISTRY.json because it is the same species of file -- machine +# managed, blessed by Patrick, the anchor of trust for a whole tier. +DECLARED_ROOTS = "AIPASS_ROOTS.json" +EXTERNAL_REGISTRY_GLOB = "*_REGISTRY.json" + +# The exact-case ending both globs are narrowed to. Windows and macOS glob +# case-insensitively, so the pattern alone is wider than the rule it spells. +CORE_REGISTRY_SUFFIX = "_REGISTRY.json" + + +# The root implied by this SOURCE TREE's layout, re-exported from the one +# module that computes it. Named here because this module's own pins read it. +_SOURCE_ROOT = repo_root.SOURCE_ROOT + def find_repo_root(start: Path | None = None) -> Path: - """Walk up from *start* to the directory holding ``AIPASS_REGISTRY.json``.""" - current = Path(start) if start is not None else Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / CORE_REGISTRY).exists(): - return parent - return Path.cwd() + """Walk up from *start* to the directory holding ``AIPASS_REGISTRY.json``. + + Falls back to the root implied by the SOURCE TREE's location — never to the + process working directory. Two defects lived in the ``Path.cwd()`` this + replaced: + + THE LOUD ONE, reported by @drone with an isolated repro. ``REPO_ROOT`` is + resolved at MODULE level, and a clean checkout has no registry (it is + gitignored and machine-local), so a bare CI runner took the fallback on + every import — and a process whose working directory has been deleted + raises ``FileNotFoundError`` from ``Path.cwd()`` while merely IMPORTING + this module. It took down every import of drone on CI, router and + ``drone rm`` included, because their handler imported the gateway at module + level. They fixed their half and reported this line rather than patching + another branch's tree. + + THE QUIET ONE, which would have outlived the crash: cwd is a GUESS. The + directory a process happened to start in says nothing about where this + source file lives, so on a registry-less tree every fleet lane would have + resolved against whatever the caller's shell was pointing at — silently, + and differently per caller. That is the species Patrick outlawed and the + same objection @drone raised against ``_first_registry_in``: a fallback + wearing a determinism costume. + + THE BODY LIVES IN ``handlers/repo_root.py``, and that is the third lesson. + Curing it here cured ONE of ten byte-identical copies; CI went red on + ``detector.py`` — same package, one file over — inside the hour, caught by + the very pin written for this fix. A fix that lands on some of N identical + paths is not a fix, so there is one implementation now and this is a name + for it. The fleet gateway still exports this name because callers ask + ``registry_scope`` where the repo is, and that is the right door for them. + + Args: + start: Directory to walk up from. Defaults to the source tree's own + location. + + Returns: + The directory holding ``CORE_REGISTRY``, or ``_SOURCE_ROOT`` when no + registry exists anywhere above *start*. Never reads the process cwd. + """ + return repo_root.find_repo_root(start, marker=CORE_REGISTRY, caller="registry_scope") REPO_ROOT = find_repo_root() @@ -140,7 +199,23 @@ def declared_residency(branch_path: Path) -> str | None: except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc: logger.error(f"[registry_scope] Unreadable passport {passport}: {exc}") return None - residency = data.get("citizenship", {}).get("residency") + # SHAPE, not just parseability. Reported by @daemon 2026-08-30 (dispatch + # 5031a591): valid JSON that is not an object made `.get` raise + # AttributeError straight out of this function — and this function is not a + # leaf. `fleet_branches` calls it once per core citizen and + # `_accepted_residents` once per candidate, so one malformed file took out + # every fleet lane at once, with a traceback naming this module instead of + # the passport. The docstring above is the specification: unreadable + # declares NOTHING and does not raise. A non-dict root is unreadable. + if not isinstance(data, dict): + logger.error(f"[registry_scope] Passport root is {type(data).__name__}, not an object: {passport}") + return None + citizenship = data.get("citizenship") + if not isinstance(citizenship, dict): + if citizenship is not None: + logger.error(f"[registry_scope] Passport citizenship is {type(citizenship).__name__} in {passport}") + return None + residency = citizenship.get("residency") return residency if isinstance(residency, str) else None @@ -172,13 +247,44 @@ def resident_registry_paths(repo_root: Path | None = None) -> list[Path]: return [] found = [] - for path in sorted(projects.glob(RESIDENT_REGISTRY_GLOB)): + for path in _exactly_named(sorted(projects.glob(RESIDENT_REGISTRY_GLOB)), CORE_REGISTRY_SUFFIX): if any(part.startswith(".") for part in path.relative_to(projects).parts): continue found.append(path) return found +def _exactly_named(candidates: list[Path], suffix: str) -> list[Path]: + """Keep only the candidates whose filename ends with *suffix* in EXACT case. + + ``Path.glob`` is case-insensitive on Windows and macOS, so ``*_REGISTRY.json`` + also matches ``drone_command_registry.json`` there. @drone hit this on the + Windows CI leg with a real file in their own tree; @devpulse routed it to + every walk owner and this is mine. + + IT IS NOT A COSMETIC WIDENING. Both walks here treat the match count as + meaningful: a declared root with no registry is an ERROR, and one with more + than one is a REFUSAL by name rather than a sorted()[0] pick. So a spurious + lowercase match does not add a stray citizen — it turns a root that works on + Linux into a root that refuses on Windows, for a file nobody declared. + + The glob still runs first; this only narrows. A filter that has to be + applied at two call sites lives in one function so a third walk cannot be + written without it. + + Args: + candidates: Paths returned by a glob. + suffix: The exact-case filename ending required. + + Returns: + The candidates whose ``name`` genuinely ends with *suffix*. + """ + # The body lives in handlers/repo_root.py: detector and memory_watcher + # carry the same walk, and a filter re-implemented per walk is how the + # ten-copy _find_repo_root defect got written in the first place. + return repo_root.exactly_named(candidates, suffix) + + def read_registry_branches(registry_path: Path, name_from: str = "path") -> list[dict[str, Any]]: """Read one registry's ACTIVE branches with absolute paths. @@ -201,30 +307,272 @@ def read_registry_branches(registry_path: Path, name_from: str = "path") -> list logger.error(f"[registry_scope] Unreadable registry {registry_path}: {exc}") return [] + # Same shape defect as `declared_residency`, and worse here: a passport is + # agent-written and expected to be wrong sometimes, while every lane trusts + # the registry as the anchor a passport cannot forge. A registry that + # crashes the reader takes the anchor down with it. Found by extending + # @daemon's report rather than by their tests, which only reached the + # passport. + if not isinstance(data, dict): + logger.error(f"[registry_scope] Registry root is {type(data).__name__}, not an object: {registry_path}") + return [] + branches = data.get("branches", []) + if not isinstance(branches, list): + logger.error(f"[registry_scope] Registry 'branches' is {type(branches).__name__}, not a list: {registry_path}") + return [] + found = [] - for branch in data.get("branches", []): + for branch in branches: + # One bad row costs one row. A fleet where a single typo hides every + # other citizen is not failing honestly — it is failing loudly in the + # wrong place. + if not isinstance(branch, dict): + logger.error(f"[registry_scope] Registry row is {type(branch).__name__}, not an object: {registry_path}") + continue if branch.get("status") != "active": continue raw = branch.get("path", "") + if not isinstance(raw, str): + logger.error( + f"[registry_scope] Registry row '{branch.get('name')}' has a non-string path in {registry_path}" + ) + continue if not raw: continue path = Path(raw) if not path.is_absolute(): path = Path(registry_path).parent / raw name = path.name if name_from == "path" else branch.get("name", path.name) - found.append({"name": name, "path": path, "registry": Path(registry_path).name}) + # Wrong TYPE is a wrong answer rather than a crash, so neither of these + # is caught by the guards above — and both are still wrong. An int name + # breaks any caller that formats it; a non-string address is unmailable + # for @daemon and @ai_mail. The name has an honest fallback (the + # directory); an address does not, so it becomes None and the caller + # refuses on its own terms. + if not isinstance(name, str): + logger.error(f"[registry_scope] Registry row at {raw} has a non-string name in {registry_path}") + name = path.name + email = branch.get("email") + if email is not None and not isinstance(email, str): + logger.error(f"[registry_scope] Registry row '{name}' has a non-string email in {registry_path}") + email = None + # Pass-through, never derived. ``name`` may come from the DIRECTORY, so + # deriving an address from it would hand an email-addressed caller a + # plausible wrong answer instead of a missing one. Absent stays None and + # the branch is kept: path-based lanes never read the address, and + # dropping a citizen here would break them to protect a caller that has + # not asked. Requested by @daemon, 2026-08-30, dispatch 16fbf1c0. + found.append( + { + "name": name, + "path": path, + "registry": Path(registry_path).name, + "email": email or None, + } + ) return found -def _refuse(item: dict[str, Any], registry_path: Path, reason: str) -> None: - """Log a refused resident candidate by name, path and reason. +def overlaps_home(candidate: Path, home: Path) -> bool: + """Does *candidate* sit inside AIPass home, contain it, or equal it? + + THE DOUBLE-COUNT GUARD, and it is not hypothetical. Declaring our own tree + as an external root would return every core citizen and every resident a + second time under a different tier; @baud would appear three times, because + the backup copy the walk law already refuses is a third path to the same + branch. A root that CONTAINS home is refused for the same reason from the + other side -- declaring ``..`` would sweep this repo in as somebody's + sibling. + + Public because the anchor's WRITE lane refuses the same thing at write time, + against this exact function. Two enforcement points, one predicate. + + Both paths must already be resolved; this compares, it does not normalise. + """ + return candidate == home or home in candidate.parents or candidate in home.parents + + +def declared_roots(repo_root: Path | None = None) -> list[Path]: + """Repo roots this installation has DECLARED as participating, resolved. + + The anchor for the external tier. Reading a file, not searching a disk: + every root here was written down by someone, and a root nobody wrote down is + not a root no matter how close it sits. + + The two anchors this replaces both failed in production, which is why it is + a declaration rather than an accumulation. ``ai_mail``'s contacts.json + accretes by ``last_seen``, still carries dead April entries and does not + contain @wren at all. My own ``known_registries.json`` persisted a deleted + /tmp scratchpad probe while never recording Vera-Studio's real registry. + + Relative paths resolve against *repo_root* so ``../wren`` survives a + checkout move and keeps machine paths out of a public repo; absolute paths + are accepted for roots that live nowhere near it. + + Every rejection is logged by name. A missing file is NOT one of them: zero + declared roots is the ordinary state of a fresh clone, and an installation + that participates in nothing is not broken. + + Args: + repo_root: AIPass home; defaults to this checkout's. + + Order is DECLARATION order, and that is load-bearing rather than + incidental. The fleet ruling breaks an N-root tie by declaration order, so + a reader that sorted its result put alphabetical-by-resolved-path in front + of the rule and the tie-breaker was never available at any door. Both + orders are deterministic; only the rows a human wrote carry intent, and + directory names carry none. Reported by @ai_mail (abe8141b), who found the + order at their door could not be the order the ruling names and said so + instead of guessing. + + Returns: + Existing directories, resolved, deduplicated, in the order declared. + First declaration wins a duplicate. Never this repo. + """ + root = Path(repo_root) if repo_root is not None else REPO_ROOT + anchor = root / DECLARED_ROOTS + try: + data = json.loads(anchor.read_text(encoding="utf-8")) + except FileNotFoundError: + logger.debug(f"[registry_scope] No {DECLARED_ROOTS} at {root} — no external roots declared") + return [] + except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc: + logger.error(f"[registry_scope] Unreadable {DECLARED_ROOTS} at {anchor}: {exc}") + return [] + + if not isinstance(data, dict): + logger.error(f"[registry_scope] {DECLARED_ROOTS} root is {type(data).__name__}, not an object: {anchor}") + return [] + rows = data.get("roots", []) + if not isinstance(rows, list): + logger.error(f"[registry_scope] {DECLARED_ROOTS} 'roots' is {type(rows).__name__}, not a list: {anchor}") + return [] + + home = root.resolve() + found: list[Path] = [] + seen: set[str] = set() + for row in rows: + if not isinstance(row, dict): + logger.error(f"[registry_scope] {DECLARED_ROOTS} row is {type(row).__name__}, not an object: {anchor}") + continue + if row.get("status") != "active": + logger.info(f"[registry_scope] Declared root {row.get('path')!r} is not active — skipped") + continue + raw = row.get("path") + if not isinstance(raw, str) or not raw: + logger.error(f"[registry_scope] Declared root has no usable path ({raw!r}) in {anchor}") + continue + + candidate = Path(raw) + if not candidate.is_absolute(): + candidate = root / candidate + try: + candidate = candidate.resolve() + except OSError as exc: + logger.error(f"[registry_scope] Declared root {raw!r} cannot be resolved: {exc}") + continue + if not candidate.is_dir(): + logger.error(f"[registry_scope] Declared root {raw!r} is not a directory on this machine ({candidate})") + continue + # The double-count guard. Extracted as a public predicate so the WRITE + # side refuses the same thing at the same place in the same words: a + # rule enforced by two functions is a rule that agrees by coincidence. + if overlaps_home(candidate, home): + logger.error( + f"[registry_scope] REFUSED declared root {raw!r} ({candidate}): it overlaps AIPass home " + f"{home} — core and resident citizens would be counted twice" + ) + continue + + key = str(candidate) + if key in seen: + continue + seen.add(key) + found.append(candidate) + + # NOT sorted(): see the docstring. Declaration order is the tie-breaker the + # fleet ruling names, and sorting here discarded it before any caller saw it. + return found + + +def external_branches(repo_root: Path | None = None, name_from: str = "path") -> list[dict[str, Any]]: + """Citizens living in declared roots outside this repo. + + Registry-led and shallow, exactly as the resident tier is: one glob for + ``*_REGISTRY.json`` at the TOP LEVEL of a declared root, then that + registry's own active branches. Never a passport walk, at any depth, ever + -- a walk of our own ``projects/`` returns eight passports for four + residents because @baud carries copies under ``.backup/``, and the same walk + across a whole machine would count every snapshot of every repo. + + MEMBERSHIP IS PRESENCE. A branch is a citizen if ``.trinity/passport.json`` + exists; it does not have to say anything. That is Patrick's ruling and the + reason phase 2 shipped without a schema migration in front of it. + + The record carried a ``scheduler`` bool for three hours on 2026-08-30 and + no longer does. @daemon asked for it, then asked for it back: it reported + one filename while they read ``.daemon/*.json``, so as a pre-filter it would + have silently dropped jobs living in any other file -- reading as "no jobs" + rather than as a bug. Withdrawn on their word, by the branch that wanted it. + + Args: + repo_root: AIPass home; defaults to this checkout's. + name_from: See :func:`read_registry_branches`. + + Returns: + ``[{"name", "path", "registry", "email", "residency"}]``. + """ + found: list[dict[str, Any]] = [] + seen: set[str] = set() + for root in declared_roots(repo_root): + registries = _exactly_named(sorted(root.glob(EXTERNAL_REGISTRY_GLOB)), CORE_REGISTRY_SUFFIX) + if not registries: + logger.error( + f"[registry_scope] Declared root {root} carries no {EXTERNAL_REGISTRY_GLOB} at its top level — " + "no citizens read from it, and a passport walk is never the fallback" + ) + continue + if len(registries) > 1: + # Raised by @drone against their OWN code: their _first_registry_in + # takes sorted(glob)[0], which they called a fallback wearing a + # determinism costume, and recommended I not copy it. Merging would + # be worse than picking — it invents a union nobody declared and + # makes that repo's fleet a thing only this reader knows. So the + # root contributes nothing and says why. One ambiguous root costs + # one root; the others are untouched. + logger.error( + f"[registry_scope] REFUSED declared root {root}: it carries {len(registries)} registries " + f"({', '.join(path.name for path in registries)}) — which one is the fleet is not ours to guess" + ) + continue + for registry_path in registries: + for item in read_registry_branches(registry_path, name_from=name_from): + if not (item["path"] / PASSPORT_RELATIVE).is_file(): + _refuse( + item, + registry_path, + "no passport on disk — external membership is presence", + tier=RESIDENCY_EXTERNAL, + ) + continue + key = str(item["path"]) + if key in seen: + continue + seen.add(key) + item["residency"] = RESIDENCY_EXTERNAL + found.append(item) + return found + + +def _refuse(item: dict[str, Any], registry_path: Path, reason: str, tier: str = RESIDENCY_RESIDENT) -> None: + """Log a refused candidate by tier, name, path and reason. Every rejection goes through here so none of them can be silent. A candidate refused without a line in the log is indistinguishable from one that was never discovered, and those two need very different fixes. """ logger.error( - f"[registry_scope] REFUSED resident '{item['name']}' at {item['path']} " + f"[registry_scope] REFUSED {tier} '{item['name']}' at {item['path']} " f"(listed active in {registry_path.name}): {reason}" ) @@ -278,7 +626,7 @@ def accepted_resident_paths(repo_root: Path | None = None) -> set[str]: def fleet_branches(repo_root: Path | None = None, name_from: str = "path") -> list[dict[str, Any]]: - """Every branch @memory maintains: the core citizens plus declared residents. + """Every branch @memory maintains: core citizens, residents, and externals. Deduplicated by resolved path, core registry first, residents in discovery order. @@ -296,7 +644,7 @@ def fleet_branches(repo_root: Path | None = None, name_from: str = "path") -> li name_from: See :func:`read_registry_branches`. Returns: - ``[{"name", "path", "registry"}]``. + ``[{"name", "path", "registry", "email", "residency"}]``. """ root = Path(repo_root) if repo_root is not None else REPO_ROOT branches = read_registry_branches(root / CORE_REGISTRY, name_from=name_from) @@ -309,12 +657,27 @@ def fleet_branches(repo_root: Path | None = None, name_from: str = "path") -> li f"{residency!r}, not '{RESIDENCY_CORE}' — kept, because the sealed registry is the anchor" ) + # The TIER is applied here, not read from the passport. A core citizen whose + # passport disagrees is still core (the sealed registry is the anchor), and + # an external citizen has no passport field at all -- so a record labelled + # from the declaration would be blank for a whole tier. @daemon asked for + # these labels by name so 'external' never silently reads as 'core'. + for item in branches: + item["residency"] = RESIDENCY_CORE + seen = {str(item["path"]) for item in branches} for registry_path in resident_registry_paths(root): for item in _accepted_residents(registry_path, name_from): if str(item["path"]) not in seen: + item["residency"] = RESIDENCY_RESIDENT branches.append(item) seen.add(str(item["path"])) + resident_count = len(branches) - core_count + + for item in external_branches(root, name_from=name_from): + if str(item["path"]) not in seen: + branches.append(item) + seen.add(str(item["path"])) # Logged because the SIZE of the fleet is the whole point of this module: # the residents were invisible to rollover, lint and health for months and @@ -322,7 +685,12 @@ def fleet_branches(repo_root: Path | None = None, name_from: str = "path") -> li # exact regression, and this line is where it shows up. json_handler.log_operation( "fleet_scope", - {"total": len(branches), "core": core_count, "resident": len(branches) - core_count}, + { + "total": len(branches), + "core": core_count, + "resident": resident_count, + "external": len(branches) - core_count - resident_count, + }, module_name="registry_scope", ) return branches diff --git a/src/aipass/memory/apps/handlers/monitor/roots_file.py b/src/aipass/memory/apps/handlers/monitor/roots_file.py new file mode 100644 index 000000000..bcd7b6731 --- /dev/null +++ b/src/aipass/memory/apps/handlers/monitor/roots_file.py @@ -0,0 +1,311 @@ +# =================== AIPass ==================== +# Name: roots_file.py +# Description: Template lifecycle for AIPASS_ROOTS.json — init, add, remove, list, heal +# Version: 1.0.0 +# Created: 2026-08-30 +# Modified: 2026-08-30 +# ============================================= + +"""The WRITE half of the fleet anchor. ``registry_scope`` reads; this writes. + +FPLAN-0460 phase 4. @devpulse created ``AIPASS_ROOTS.json`` by hand and Patrick +ruled on seeing it: "jsons are normally created by code, so if they corrupt or +get deleted they are always rebuilt from default settings from a template +directory." + +WHY A SEPARATE MODULE. ``registry_scope`` is the single definition of who is in +the fleet, consumed cross-branch through ``modules/fleet.py``. A reader that also +writes is two jobs in one file and one import away from a lane that meant to ask +a question and changed an answer instead. The two halves share the FILENAME and +the OVERLAP PREDICATE by import, so they cannot drift on the two things that +would matter. + +THE ASYMMETRY THIS EXISTS TO FIX. The reader already refuses a row it cannot use +— a path that does not exist, one that overlaps AIPass home, a duplicate. Refused +at READ time, that row still sits in the file, and the only trace of its being +wrong is a log line nobody is reading. Every one of those refusals now happens at +WRITE time too, against ``registry_scope.overlaps_home`` itself rather than a +copy of its logic, so the file cannot carry a declaration that will be silently +dropped. + +HEALING IS DELIBERATE, NEVER AUTOMATIC. See :func:`heal`. +""" + +import json +import re +from datetime import date +from pathlib import Path + +from aipass.prax import logger + +from aipass.memory.apps.handlers.json import json_handler +from aipass.memory.apps.handlers.monitor.registry_scope import ( + DECLARED_ROOTS, + find_repo_root, + overlaps_home, +) +from aipass.memory.apps.handlers.repo_root import module_file + +# Imported, never re-spelled: the reader owns these and the write side must +# refuse exactly what the read side refuses. `overlaps_home` is used under the +# reader's own name at its call site rather than aliased here — an alias is a +# second spelling of one rule, which is the thing this comment exists to refuse. +ROOTS_FILE = DECLARED_ROOTS + +ROOTS_SCHEMA_VERSION = "1.0.0" +TEMPLATE_NAME = "AIPASS_ROOTS.template.json" +CORRUPT_SUFFIX_NAME = f"{DECLARED_ROOTS}.corrupt" + +# Branch home is four parents up from apps/handlers/monitor/roots_file.py. +_BRANCH_ROOT = module_file(__file__).parent.parent.parent.parent + +# Best-effort salvage only. This finds path-shaped strings in a file that no +# longer parses so a human can read what was lost; nothing here is ever written +# back as a declaration. +_SALVAGE = re.compile(r'"path"\s*:\s*"([^"]+)"') + + +def template_path() -> Path: + """The gold-source template, beside the other @memory templates.""" + return _BRANCH_ROOT / "templates" / TEMPLATE_NAME + + +def _today() -> str: + return date.today().isoformat() + + +def render_scaffold(today: str | None = None) -> dict: + """The default document: current metadata, and NOBODY DECLARED. + + An empty ``roots`` is the only honest default. A template that shipped with + example roots would declare on an installation's behalf the moment it was + rendered, which is the thing this whole tier forbids. + """ + stamp = today or _today() + document = json.loads(template_path().read_text(encoding="utf-8")) + document["metadata"]["last_updated"] = stamp + document["metadata"]["version"] = ROOTS_SCHEMA_VERSION + document["roots"] = [] + return document + + +def _path_of(repo_root: Path | None) -> Path: + root = Path(repo_root) if repo_root is not None else find_repo_root() + return root / ROOTS_FILE + + +def _load(repo_root: Path | None) -> tuple[dict | None, str]: + """The document, or ``(None, why-not)``. Never raises, never repairs.""" + anchor = _path_of(repo_root) + try: + data = json.loads(anchor.read_text(encoding="utf-8")) + except FileNotFoundError: + logger.debug(f"[roots_file] No {ROOTS_FILE} at {anchor} — nothing declared yet") + return None, f"{ROOTS_FILE} does not exist — run 'roots init' to create it from the template" + except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc: + logger.error(f"[roots_file] Unreadable {anchor}: {exc}") + return None, f"{ROOTS_FILE} is unreadable ({exc}) — run 'roots heal' to rebuild the scaffold" + if not isinstance(data, dict) or not isinstance(data.get("roots"), list): + return None, f"{ROOTS_FILE} is not a roots document — run 'roots heal' to rebuild the scaffold" + return data, "" + + +def _write(repo_root: Path | None, document: dict, today: str | None) -> None: + document["metadata"]["last_updated"] = today or _today() + anchor = _path_of(repo_root) + anchor.write_text(json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def _resolve(repo_root: Path | None, raw: str) -> Path: + """A declared spelling, as the reader would resolve it.""" + root = Path(repo_root) if repo_root is not None else find_repo_root() + candidate = Path(raw) + if not candidate.is_absolute(): + candidate = root / candidate + try: + return candidate.resolve() + except OSError as exc: + logger.warning(f"[roots_file] Cannot resolve {candidate} ({exc}) — using it unresolved") + return candidate + + +def _spell(repo_root: Path | None, resolved: Path) -> str: + """Relative to home when it fits, absolute when it does not — POSIX-spelled either way. + + Relative keeps ``/home/`` out of a public repo and survives a + checkout move as long as siblings stay siblings; a root that lives nowhere + near home has no relative spelling worth reading and keeps its absolute one. + + THE SEPARATOR IS PART OF THE CONTRACT, decided 2026-08-31 after a Windows CI + red, and decided rather than worked around: a declared row is ALWAYS written + with forward slashes. ``str()`` on a Windows path yields ``..\\wren``, so the + same declaration had two spellings depending on which machine ran ``add``. + + Forward slashes because they are the only spelling BOTH platforms read: + ``Path("../wren")`` and ``Path("C:/proj/wren")`` resolve correctly on + Windows, while ``..\\wren`` is a filename on POSIX, not a path. One + spelling means a row can be compared, copied and diffed without knowing who + wrote it — and the anchor is a declaration Patrick blesses, so it has to + read the same to a human on either machine. + + Bending the test instead would have left the writer emitting whichever + separator the OS happened to prefer, which is not a contract, it is a + coincidence that has been holding. + """ + root = (Path(repo_root) if repo_root is not None else find_repo_root()).resolve() + try: + return (Path("..") / resolved.relative_to(root.parent)).as_posix() + except ValueError: + logger.debug(f"[roots_file] {resolved} is not a sibling of {root} — declaring it absolute") + return resolved.as_posix() + + +def init_roots(repo_root: Path | None = None, today: str | None = None) -> tuple[bool, str]: + """Render the scaffold, but never over something already there. + + Refuses an existing file whether it parses or not. Unreadable is not absent: + overwriting a corrupt anchor here would be the silent rebuild that + :func:`heal` exists to keep deliberate. + """ + anchor = _path_of(repo_root) + if anchor.exists(): + return False, f"{ROOTS_FILE} already exists at {anchor} — refusing to overwrite declarations" + document = render_scaffold(today) + anchor.parent.mkdir(parents=True, exist_ok=True) + _write(repo_root, document, today) + logger.info(f"[roots_file] Rendered {ROOTS_FILE} from template at {anchor} — no roots declared") + json_handler.log_operation("roots_init", {"path": str(anchor)}) + return True, f"created {anchor} from {template_path().name} — no roots declared yet" + + +def add_root( + repo_root: Path | None = None, + raw_path: str = "", + label: str = "", + today: str | None = None, +) -> tuple[bool, str]: + """Declare a root, refusing at WRITE time what the reader refuses at READ time.""" + document, problem = _load(repo_root) + if document is None: + return False, problem + + if not raw_path: + return False, "no path given" + resolved = _resolve(repo_root, raw_path) + home = (Path(repo_root) if repo_root is not None else find_repo_root()).resolve() + + if not resolved.exists(): + return False, f"{raw_path} does not exist on this machine ({resolved})" + if not resolved.is_dir(): + return False, f"{raw_path} is not a directory ({resolved})" + if overlaps_home(resolved, home): + return False, (f"{raw_path} overlaps AIPass home ({home}) — core and resident citizens would be counted twice") + for row in document["roots"]: + if isinstance(row, dict) and _resolve(repo_root, str(row.get("path", ""))) == resolved: + return False, f"{resolved} is already declared as {row.get('path')!r}" + + document["roots"].append({"path": _spell(repo_root, resolved), "label": label or resolved.name, "status": "active"}) + _write(repo_root, document, today) + logger.info(f"[roots_file] Declared root {resolved}") + json_handler.log_operation("roots_add", {"root": str(resolved)}) + return True, f"declared {resolved}" + + +def remove_root(repo_root: Path | None = None, raw_path: str = "", today: str | None = None) -> tuple[bool, str]: + """Retire a declaration. + + Matches on the RESOLVED path so any spelling of the same directory works, + and deliberately does not require the directory to still exist — retiring a + deleted repo must not mean resurrecting it first. + """ + document, problem = _load(repo_root) + if document is None: + return False, problem + + resolved = _resolve(repo_root, raw_path) + kept = [ + row + for row in document["roots"] + if not (isinstance(row, dict) and _resolve(repo_root, str(row.get("path", ""))) == resolved) + ] + if len(kept) == len(document["roots"]): + return False, f"{raw_path} is not declared in {ROOTS_FILE}" + + document["roots"] = kept + _write(repo_root, document, today) + logger.info(f"[roots_file] Retired root {resolved}") + json_handler.log_operation("roots_remove", {"root": str(resolved)}) + return True, f"retired {resolved}" + + +def list_roots(repo_root: Path | None = None) -> list[dict]: + """Every declared row plus what it actually resolves to. + + ``reachable`` is the point of the verb: a row the reader will drop looks + exactly like a working one in the file, and the only other place that shows + up is a log line. + """ + document, _ = _load(repo_root) + if document is None: + return [] + rows = [] + for row in document["roots"]: + if not isinstance(row, dict): + rows.append({"path": repr(row), "label": "", "status": "", "resolves": None, "reachable": False}) + continue + raw = str(row.get("path", "")) + resolved = _resolve(repo_root, raw) if raw else None + rows.append( + { + "path": raw, + "label": row.get("label", ""), + "status": row.get("status", ""), + "resolves": resolved, + "reachable": bool(resolved and resolved.is_dir() and row.get("status") == "active"), + } + ) + return rows + + +def heal(repo_root: Path | None = None, today: str | None = None) -> tuple[bool, str, list[str]]: + """Rebuild a broken anchor as an EMPTY scaffold, loudly, on purpose. + + DELIBERATE, NEVER AUTOMATIC, and this is the design ruling rather than an + implementation detail. If reading could trigger a rebuild, any lane that + happened to read a corrupt anchor first — rollover, lint, health, @daemon's + scheduler — would replace the operator's declarations with an empty + scaffold as a side effect. And because ZERO ROOTS IS A LEGAL STATE, nothing + downstream would fail: the system would keep running and quietly maintain + nothing. That is re-declaring on the operator's behalf, which is exactly + what declaration-is-the-credential forbids. So the reader stays a reader, + and repair is something a human asks for. + + NEVER RE-DECLARES. Path-shaped strings are recovered from the wreckage and + RETURNED for a human to read, never written back. A salvaged path is a guess + about intent, and a guess that installs itself is indistinguishable from a + declaration. + + The broken file is set aside, not deleted — a rebuild that destroys the + evidence of what it repaired cannot be audited. + """ + anchor = _path_of(repo_root) + if not anchor.exists(): + return False, f"{ROOTS_FILE} does not exist — 'roots init' creates it; heal repairs", [] + + document, problem = _load(repo_root) + if document is not None: + return False, f"nothing to heal: {anchor} parses and carries {len(document['roots'])} row(s)", [] + + raw = anchor.read_text(encoding="utf-8", errors="replace") + salvaged = _SALVAGE.findall(raw) + preserved = anchor.parent / CORRUPT_SUFFIX_NAME + preserved.write_text(raw, encoding="utf-8") + _write(repo_root, render_scaffold(today), today) + + logger.error( + f"[roots_file] REBUILT {anchor} as an EMPTY scaffold ({problem}). " + f"Original preserved at {preserved}. Declarations NOT restored: {salvaged or 'none found'} — " + "re-declare them with 'roots add', because a rebuild that repopulates has re-declared for you" + ) + json_handler.log_operation("roots_heal", {"path": str(anchor), "salvaged": salvaged}) + return True, f"rebuilt {anchor} empty; original preserved at {preserved}", salvaged diff --git a/src/aipass/memory/apps/handlers/repo_root.py b/src/aipass/memory/apps/handlers/repo_root.py new file mode 100644 index 000000000..cadd69236 --- /dev/null +++ b/src/aipass/memory/apps/handlers/repo_root.py @@ -0,0 +1,254 @@ +# =================== AIPass ==================== +# Name: repo_root.py +# Description: The one answer to "which repo root is this" — never the process cwd, never a folded filename +# Version: 1.2.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Repo root discovery, defined once. + +WHY THIS FILE EXISTS +-------------------- +On 2026-08-31 @drone reported that ``registry_scope`` crashed on import in a +process whose working directory had been deleted: a module-level walk-up whose +last resort was ``Path.cwd()``. It was cured within the hour. The SAME HOUR, +CI went red again on ``detector.py`` — the identical function, one file over, +found by the very subprocess pin written for the first fix. + +That second red is the real defect. ``_find_repo_root`` existed as TEN +byte-identical copies across this tree, so the cure landed on one of them and +nine kept the disease. A fix that lands on some of N identical paths is this +week's lesson arriving twice in one night, and the only version of it that +cannot recur is one implementation. This is that implementation. + +WHAT THE FALLBACK MUST NEVER BE +------------------------------- +``Path.cwd()`` carried two defects in one line. + +THE LOUD ONE: the walk is evaluated at MODULE level in four of the callers, and +a clean checkout has no registry (``AIPASS_REGISTRY.json`` is gitignored and +machine-local), so a bare CI runner takes the fallback on every import. When +the process's working directory has been deleted, ``Path.cwd()`` raises +``FileNotFoundError`` while merely IMPORTING the module — no call required. + +THE QUIET ONE, which would have outlived the crash: cwd is a GUESS. The +directory a process happened to start in says nothing about where this source +file lives, so on a registry-less tree every lane resolves against whatever the +caller's shell was pointing at — silently, and DIFFERENTLY PER CALLER. Several +of the callers here are WRITERS. A writer with a guessed root writes into a +tree nobody chose. A ``try``/``except`` would have fixed the traceback and kept +the wrong answer. + +The source-derived answer is not a guess: on a registry-less checkout it IS the +checkout, which is the true answer there. And the absence is said out loud, +because a fallback nobody can see is how the next one survives. + +A FILENAME IS NOT A GLOB AND NOT AN ``exists()`` (1.1.0, 2026-08-31) +------------------------------------------------------------------- +Windows and macOS-default filesystems fold case. ``*_REGISTRY.json`` matches +``flow_json_registry.json`` there, and ``(parent / "AIPASS_REGISTRY.json").exists()`` +returns True for a file actually named ``aipass_registry.json``. @drone found +the first form on the Windows CI leg; @seedgo's fleet discriminator published +the second as its own blind spot, and it is the worse of the two because there +is no glob in the line to warn a reader. + +Both belong here rather than at each walk. ``find_repo_root`` IS a cased +literal check, run at module level in four callers, so a folded bait file would +be accepted as THE REPO ROOT and every writer built on it would write into a +tree nobody chose — the quiet defect this module exists to prevent, arriving +through a different door. + +IMPORTING THIS MODULE MUST NEVER RAISE +-------------------------------------- +Four callers resolve their root at module level, so anything this file does at +import time happens during THEIR import. That is why the audit line on the +fallback path is written defensively: an operations record is worth having, and +it is never worth turning a diagnostic write into the very import crash this +module exists to prevent. + +``handlers/json/__init__`` imports ``config_loader``, so ``config_loader`` +reaches this module through a function-local import. A module-level edge back +would be a cycle, and the cycle would only appear in whichever import order CI +happened to take. +""" + +import os +from pathlib import Path + +from aipass.prax.apps.modules.logger import get_system_logger + +logger = get_system_logger() + +MODULE_NAME = "repo_root" + +# The marker that defines a repo root: the core registry file. +CORE_REGISTRY = "AIPASS_REGISTRY.json" + + +# The repo root this FILE sits in, derived from the layout and nothing else. +# ``src/`` is the marker because it is the one directory the package layout +# guarantees. The last-resort value is the filesystem root: defined, never +# raises, and absurd enough to fail loudly downstream instead of quietly +# resolving against somebody's home directory. +def module_file(file: str) -> Path: + """A module's own path, absolute, resolved when the filesystem allows. + + ``Path(__file__).resolve()`` at module level is a cwd read on Windows. + ``ntpath.realpath`` computes ``os.getcwd()`` UNCONDITIONALLY — not only for + a relative path, the way POSIX does — so in a process whose working + directory is gone, importing the module raises ``FileNotFoundError`` from a + line that only wanted to know where its own file is. Found on the Windows CI + leg 2026-08-31, one frame at a time: the guard in ``handlers/__init__`` + crashed first, and behind it sat thirty-two more module-level copies of the + same idiom. + + ``.resolve()`` is still attempted, because normalising symlinks is why the + call is there and it succeeds on every healthy machine. The fallback is only + reached in the world where the alternative is a dead import, and it is + sound: ``__file__`` has been absolute since Python 3.9, so what is returned + is the right file either way — just spelled through the symlink rather than + past it. + + Args: + file: A module's ``__file__``. + + Returns: + The module's path, resolved if the filesystem could be asked. + """ + path = Path(file) + try: + return path.resolve() + except OSError as exc: + # debug, not warning: in the world that reaches this line EVERY module + # import takes it, and thirty-two identical warnings describe one + # condition. The condition itself is reported once, loudly, by whatever + # lane actually fails on it. + logger.debug(f"[repo_root] Cannot resolve {path} ({type(exc).__name__}) — using its absolute spelling") + return path + + +_THIS_FILE = module_file(__file__) +SOURCE_ROOT = next( + (parent.parent for parent in _THIS_FILE.parents if parent.name == "src"), + _THIS_FILE.parents[-1], +) + + +def exists_exactly(path: Path) -> bool: + """True when *path* exists AND is spelled on disk exactly as asked. + + ``Path.exists()`` asks the filesystem, and Windows and macOS-default + filesystems answer about a case-folded name. So a directory holding + ``aipass_registry.json`` reports True for ``AIPASS_REGISTRY.json``, and a + caller that meant the one blessed filename silently gets a different file. + + The only reliable way to learn the real spelling is to LIST the parent — + ``resolve()`` would work on Windows but follows symlinks, so a legitimately + symlinked registry would come back under its target's name and be refused. + The listing is cheap where it matters: it only runs when ``exists()`` + already said yes, which in a walk is at most once. + + An unlistable parent returns True rather than False. This is a READ anchor, + and today's behaviour is ``exists()`` alone; refusing a file that is + demonstrably there because its directory could not be enumerated would be a + new failure invented by the guard. + + Args: + path: The exact filename being asserted. + + Returns: + True when a directory entry with that exact name exists. + """ + candidate = Path(path) + if not candidate.exists(): + return False + try: + with os.scandir(candidate.parent) as entries: + return any(entry.name == candidate.name for entry in entries) + except OSError as exc: + logger.debug( + f"[repo_root] Cannot enumerate {candidate.parent} ({exc}) — trusting exists() for {candidate.name}" + ) + return True + + +def exactly_named(candidates: list[Path], suffix: str) -> list[Path]: + """Keep only the candidates whose filename ends with *suffix* in EXACT case. + + The post-filter for every ``*_REGISTRY.json`` glob in this tree. A glob is + a pattern the OS interprets; the rule it spells is about names, and on a + folding filesystem those are not the same set. + + The narrowing only ever removes, and it never reorders — callers sort + before filtering and the order is the answer in at least one of them. + + Args: + candidates: Paths returned by a glob. + suffix: The exact-case filename ending required. + + Returns: + The candidates whose ``name`` genuinely ends with *suffix*. + """ + return [path for path in candidates if path.name.endswith(suffix)] + + +def _record_fallback(caller: str, marker: str, current: Path) -> None: + """Log the fallback loudly, and record it without ever raising. + + Called only from the fallback branch, which on four of the callers runs at + module import time. ``log_operation`` writes a file, and a write that fails + in a bare world must not become the import crash this module prevents. + + Args: + caller: Lane that took the fallback. + marker: Filename that was searched for. + current: Directory the walk started from. + """ + logger.warning( + f"[{caller}] No {marker} above {current} — " + f"resolving to the source tree at {SOURCE_ROOT}, never the process directory" + ) + try: + from aipass.memory.apps.handlers.json import json_handler + + json_handler.log_operation( + "repo_root_fallback", + {"caller": caller, "marker": marker, "searched_from": str(current), "resolved": str(SOURCE_ROOT)}, + module_name=MODULE_NAME, + ) + except Exception as exc: # noqa: BLE001 - an audit line must never take an import down + logger.debug(f"[{caller}] repo_root fallback not recorded: {type(exc).__name__}: {exc}") + + +def find_repo_root(start: Path | None = None, *, marker: str = CORE_REGISTRY, caller: str = MODULE_NAME) -> Path: + """Walk up from *start* to the directory holding *marker*. + + Falls back to the root implied by THIS FILE's location — never to the + process working directory. See the module docstring for the two defects + that fallback carried. + + Args: + start: Directory to walk up from. Defaults to this file's directory, + which is what every caller in this tree wants: they all live under + the same ``src/aipass/memory`` package, so it is the same walk + whichever of them asks. + marker: Filename that marks a repo root. + caller: Name used in the log line, so a fallback names the lane that + took it rather than reporting anonymously. + + Returns: + The directory holding *marker*, or ``SOURCE_ROOT`` when no *marker* + exists anywhere above *start*. Never reads the process cwd. + """ + # module_file, not resolve(): four callers reach this line at IMPORT time, + # and on Windows resolve() reads the working directory. The first sweep of + # this species keyed on "module level" and skipped this line for sitting + # inside a function — the discriminator that matters is REACHED AT IMPORT, + # not written at module scope, and this was the last crash standing. + current = Path(start) if start is not None else module_file(__file__).parent + for parent in [current] + list(current.parents): + if exists_exactly(parent / marker): + return parent + _record_fallback(caller, marker, current) + return SOURCE_ROOT diff --git a/src/aipass/memory/apps/handlers/rollover/extractor.py b/src/aipass/memory/apps/handlers/rollover/extractor.py index d5a55fe6c..6a47cec9e 100644 --- a/src/aipass/memory/apps/handlers/rollover/extractor.py +++ b/src/aipass/memory/apps/handlers/rollover/extractor.py @@ -125,8 +125,37 @@ def _read_memory_file(file_path: Path) -> Dict[str, Any] | None: def _write_memory_file(file_path: Path, data: Dict[str, Any]) -> None: - """Write memory JSON file using json_handler""" - write_memory_file_simple(file_path, data) + """Write memory JSON file, RAISING when the write is refused. + + `write_memory_file_simple` reports failure by returning False — it does not + raise. This function used to discard that boolean, which made every caller's + try/except decorative and turned a refusal into a silent no-op. + + That is the worst possible failure mode for an archiver, and it was live: + ONE 343-character session summary in @seedgo's file refused every rollover + write to it. The extraction still reported the 12 key_learnings it had + removed in memory, the orchestrator saw success with old_lines == new_lines, + vectorized them, stored them — and the file kept all 27. The next run + extracted the same 12 and stored them again. The archive was accepting, + permanently and in duplicate, what the source never gave up. + + Making the False loud then exposed the defect BEHIND it: the refusal itself + was wrong. The cap gate judged the whole document, so a shrink write was + refused for an entry in the head it is not allowed to touch, and the lane + re-failed identically every 20 minutes for three hours. Since 2026-08-30 a + write is judged by what it AUTHORS (entry_limits 1.6.0), and rollover + authors nothing — it removes and reorders. A cap refusal can no longer + reach this function; what remains here is real I/O failure. + + Fail to errors, never fall back silently: a False here becomes an OSError so + the caller's existing restore-from-backup path can do its job. + """ + if not write_memory_file_simple(file_path, data): + raise OSError( + f"write refused for {file_path} — the memory-file writer returned False. " + f"Rollover authors no text, so a cap refusal here would be a bug in the " + f"gate, not drift in the file; see prax for the writer's own reason" + ) def _count_file_lines(file_path: Path) -> int: @@ -171,7 +200,9 @@ def _derive_branch_and_type(file_path: Path) -> tuple[str, str]: # ============================================================================= -def _is_misplaced_entry(entry: Any, head_number: int | None, date_guard: bool = True) -> bool: +def _is_misplaced_entry( + entry: Any, head_number: int | None, date_guard: bool = True, head_date: str | None = None +) -> bool: """ An entry in the tail (about to be archived as "oldest") that is dated today or numbered above the array's head is not oldest history — it's a @@ -182,18 +213,47 @@ def _is_misplaced_entry(entry: Any, head_number: int | None, date_guard: bool = an entry is fresh (see the auto-compact lane in _extract_items_v2). Ordering still decides, and when ordering CANNOT decide — no usable number on the entry or the head — the date rule stays on, whatever the caller asked for. + + "DATED TODAY" IS ONLY EVIDENCE IF THE HEAD IS NOT (2026-08-30). The date + rule catches a fresh write that landed at the wrong end, and it works by + contrast: the tail is dated today while the entries above it are not. In an + array whose NEWEST entry is also dated today, "dated today" separates + nothing — every entry in it could be, and on 2026-08-30 every entry in + @memory's key_learnings was, because they were all written on that one very + long day. All 12 archivable candidates were refused as fresh writes, the + file sat at 27/15, and the detector re-fired on it every run. Three branches + were in that state at once. + + So the rule now needs both halves to be uninformative before it steps + aside: the candidate must be numbered strictly BELOW `head_date`'s entry + (ordering says it is at the correct end) AND the head must itself be dated + today (the date says nothing about which end anything is at). Either signal + alone still refuses. + + Deliberately unchanged: numbered ABOVE the head is refused, because that is + the real convention-loss shape — a prepend became an append, so numbers + ascend into the tail. A candidate with no usable number is refused on its + date, because ordering genuinely cannot decide there. And an unknown + `head_date` is treated as "not today", the conservative reading. """ if not isinstance(entry, dict): return False number = entry.get("number") + ordering_says_correct_end = False if isinstance(number, int) and isinstance(head_number, int): if number > head_number: return True if not date_guard: return False + ordering_says_correct_end = number < head_number + + if entry.get("date") != datetime.now().strftime("%Y-%m-%d"): + return False - return entry.get("date") == datetime.now().strftime("%Y-%m-%d") + # Dated today. That is evidence of a misplaced fresh write only when it + # DISTINGUISHES this entry from the array's newest one. + return not (ordering_says_correct_end and head_date == datetime.now().strftime("%Y-%m-%d")) def _ensure_newest_first(entries: list, array_name: str, branch_key: str) -> tuple[list, bool]: @@ -234,6 +294,7 @@ def _extract_tail_excess( array_name: str, branch_key: str, date_guard: bool = True, + head_date: str | None = None, ) -> list: """ Select the oldest entries beyond `limit` for archival, holding back any @@ -260,7 +321,7 @@ def _extract_tail_excess( archivable = [] refused: list = [] for entry in candidate_tail: - if _is_misplaced_entry(entry, head_number, date_guard=date_guard): + if _is_misplaced_entry(entry, head_number, date_guard=date_guard, head_date=head_date): refused.append(entry) # Per-entry detail at DEBUG: recoverable when someone is actually # debugging, without a wall of it on every routine run. @@ -345,7 +406,13 @@ def _extract_items_v2(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: data["sessions"] = sessions order_repaired = True session_limits = file_limits.get("sessions", {}) - head_number = sessions[0].get("number") if isinstance(sessions[0], dict) else None + # Each LANE gets its own head, taken from its own entries. `sessions` is + # a mixed array and a snapshot usually outranks every regular entry after + # the newest-first re-sort, so sharing one head handed the regular lane a + # machine-written snapshot as its reference point — dated today several + # times a day, which is precisely the noise date_guard=False exists to + # keep out of the other lane. The head must come from the population the + # candidates come from. auto_compact_cap = session_limits.get("auto_compact_cap") if auto_compact_cap is not None: @@ -354,13 +421,15 @@ def _extract_items_v2(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: # one is nearly always dated today. Keeping the date rule here refused every # candidate and the detector re-fired on the same file forever (DPLAN-0290 # item 3): the lane's order is what says which snapshot is oldest, not its date. + auto_head = auto_entries[0] if auto_entries and isinstance(auto_entries[0], dict) else {} archived_auto = _extract_tail_excess( auto_entries, auto_compact_cap, - head_number, + auto_head.get("number"), "sessions(auto-compact)", branch_key, date_guard=False, + head_date=auto_head.get("date"), ) if archived_auto: archived_ids = {id(e) for e in archived_auto} @@ -370,7 +439,15 @@ def _extract_items_v2(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: max_sessions = session_limits.get("count") if max_sessions is not None: regular_entries = [e for e in sessions if not (isinstance(e, dict) and e.get("status") == "auto-compact")] - archived_regular = _extract_tail_excess(regular_entries, max_sessions, head_number, "sessions", branch_key) + regular_head = regular_entries[0] if regular_entries and isinstance(regular_entries[0], dict) else {} + archived_regular = _extract_tail_excess( + regular_entries, + max_sessions, + regular_head.get("number"), + "sessions", + branch_key, + head_date=regular_head.get("date"), + ) if archived_regular: archived_ids = {id(e) for e in archived_regular} sessions = [e for e in sessions if id(e) not in archived_ids] @@ -387,9 +464,14 @@ def _extract_items_v2(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: order_repaired = True max_key_learnings = file_limits.get("key_learnings", {}).get("count") if max_key_learnings is not None and isinstance(key_learnings, list) and key_learnings: - kl_head_number = key_learnings[0].get("number") if isinstance(key_learnings[0], dict) else None + kl_head = key_learnings[0] if isinstance(key_learnings[0], dict) else {} archived_kl = _extract_tail_excess( - key_learnings, max_key_learnings, kl_head_number, "key_learnings", branch_key + key_learnings, + max_key_learnings, + kl_head.get("number"), + "key_learnings", + branch_key, + head_date=kl_head.get("date"), ) if archived_kl: archived_ids = {id(e) for e in archived_kl} @@ -405,8 +487,15 @@ def _extract_items_v2(file_path: Path, data: Dict[str, Any]) -> Dict[str, Any]: order_repaired = True max_observations = file_limits.get("observations", {}).get("count") if max_observations is not None and isinstance(observations, list) and observations: - obs_head_number = observations[0].get("number") if isinstance(observations[0], dict) else None - archived_obs = _extract_tail_excess(observations, max_observations, obs_head_number, "observations", branch_key) + obs_head = observations[0] if isinstance(observations[0], dict) else {} + archived_obs = _extract_tail_excess( + observations, + max_observations, + obs_head.get("number"), + "observations", + branch_key, + head_date=obs_head.get("date"), + ) if archived_obs: archived_ids = {id(e) for e in archived_obs} data["observations"] = [e for e in observations if id(e) not in archived_ids] diff --git a/src/aipass/memory/apps/handlers/rollover/orchestrator.py b/src/aipass/memory/apps/handlers/rollover/orchestrator.py index 25cbc158c..e38c6dc28 100644 --- a/src/aipass/memory/apps/handlers/rollover/orchestrator.py +++ b/src/aipass/memory/apps/handlers/rollover/orchestrator.py @@ -25,6 +25,7 @@ from pathlib import Path from typing import List, Dict, Any +from aipass.memory.apps.handlers import repo_root from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler @@ -34,14 +35,15 @@ from aipass.memory.apps.handlers.monitor import detector from aipass.memory.apps.handlers.rollover import extractor from aipass.memory.apps.handlers.tracking import line_counter +from aipass.memory.apps.handlers.repo_root import module_file # Subprocess scripts for ML operations (run in memory venv) -_HANDLERS_DIR = Path(__file__).resolve().parent.parent +_HANDLERS_DIR = module_file(__file__).parent.parent CHROMA_SUBPROCESS_SCRIPT = _HANDLERS_DIR / "storage" / "chroma_subprocess.py" EMBED_SUBPROCESS_SCRIPT = _HANDLERS_DIR / "vector" / "embed_subprocess.py" # Memory venv python — auto-detect from memory/.venv/ or use env var override -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] _MEMORY_VENV_PYTHON = _MEMORY_ROOT / ".venv" / "bin" / "python" @@ -64,12 +66,17 @@ def _get_memory_python() -> str: def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Repo root for this lane — resolved by ``handlers/repo_root.py``. + + Kept as a local name because callers and tests patch it here. The body is a + delegation on purpose: this function used to be one of ten byte-identical + copies, so the first cure landed on one file and CI went red on the next. + + Returns: + The directory holding AIPASS_REGISTRY.json, or the source tree. Never + the process working directory. + """ + return repo_root.find_repo_root(caller="orchestrator") _REPO_ROOT = _find_repo_root() @@ -294,6 +301,7 @@ def execute_rollover() -> Dict[str, Any]: "triggers_count": 0, "success_count": 0, "failed": [], + "skipped": [], } triggers = triggers_result.get("triggers", []) @@ -304,6 +312,7 @@ def execute_rollover() -> Dict[str, Any]: "triggers_count": 0, "success_count": 0, "failed": [], + "skipped": [], "results": [], } @@ -312,6 +321,7 @@ def execute_rollover() -> Dict[str, Any]: # Process each trigger success_count = 0 failed = [] + skipped = [] results = [] for trigger in triggers: @@ -342,7 +352,14 @@ def execute_rollover() -> Dict[str, Any]: continue if extract_result.get("skipped"): - logger.info(f"[rollover] Extraction skipped for {trigger}: {extract_result.get('message', 'no excess')}") + # Reported, never dropped. A trigger that increments neither tally + # produced "0/1 successful" beside an empty failure list — a count + # saying something broke and a list saying nothing did. The skip is + # also not reliably a no-op: the extractor persists a newest-first + # order repair on its way out of this same branch. + reason = extract_result.get("message", "no excess") + logger.info(f"[rollover] Extraction skipped for {trigger}: {reason}") + skipped.append({"trigger": str(trigger), "reason": reason}) continue memories = extract_result.get("entries", []) @@ -524,21 +541,30 @@ def execute_rollover() -> Dict[str, Any]: except Exception as e: logger.info(f"[rollover] Memory pool check: {e}") + # Gated on success_count alone, a run whose every trigger was legitimately + # SKIPPED reported failure while nothing had gone wrong -- "nothing needed + # archiving" is an outcome, not a fault. The existing contract that a mixed + # run still succeeds is deliberately preserved: partial progress with the + # failures printed by name is not the same as a broken run. + run_ok = not failed or success_count > 0 + json_handler.log_operation( "rollover_execute", { "triggers": len(triggers), "success_count": success_count, "failed_count": len(failed), - "success": success_count > 0 or len(triggers) == 0, + "skipped_count": len(skipped), + "success": run_ok, }, ) return { - "success": success_count > 0 or len(triggers) == 0, + "success": run_ok, "triggers_count": len(triggers), "success_count": success_count, "failed": failed, + "skipped": skipped, "results": results, } diff --git a/src/aipass/memory/apps/handlers/schema/normalize.py b/src/aipass/memory/apps/handlers/schema/normalize.py index 7bc07702f..2ec7d2783 100644 --- a/src/aipass/memory/apps/handlers/schema/normalize.py +++ b/src/aipass/memory/apps/handlers/schema/normalize.py @@ -22,6 +22,7 @@ from pathlib import Path from typing import Dict, Any +from aipass.memory.apps.handlers import repo_root from aipass.prax.apps.modules.logger import get_system_logger from aipass.memory.apps.handlers.json import json_handler @@ -125,12 +126,17 @@ def _sort_container_newest_first(container: list, container_name: str, changes: def _find_repo_root() -> Path: - """Walk up from this file to find repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Repo root for this lane — resolved by ``handlers/repo_root.py``. + + Kept as a local name because callers and tests patch it here. The body is a + delegation on purpose: this function used to be one of ten byte-identical + copies, so the first cure landed on one file and CI went red on the next. + + Returns: + The directory holding AIPASS_REGISTRY.json, or the source tree. Never + the process working directory. + """ + return repo_root.find_repo_root(caller="normalize") def normalize_memory_file(file_path: Path, dry_run: bool = False) -> Dict[str, Any]: @@ -262,7 +268,10 @@ def normalize_all_memory_files(dry_run: bool = False) -> Dict[str, Any]: # Read registry registry_path = _find_repo_root() / "AIPASS_REGISTRY.json" - if not registry_path.exists(): + # exists_exactly, not exists(): a folding filesystem answers this question + # about aipass_registry.json, and this lane REWRITES every memory file the + # answer names. See handlers/repo_root.py. + if not repo_root.exists_exactly(registry_path): return {"success": False, "error": "AIPASS_REGISTRY.json not found"} try: diff --git a/src/aipass/memory/apps/handlers/search/query_executor.py b/src/aipass/memory/apps/handlers/search/query_executor.py index c2f745656..0d65961eb 100644 --- a/src/aipass/memory/apps/handlers/search/query_executor.py +++ b/src/aipass/memory/apps/handlers/search/query_executor.py @@ -28,14 +28,15 @@ from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler +from aipass.memory.apps.handlers.repo_root import module_file # Subprocess scripts for ML operations (run in memory venv) -_HANDLERS_DIR = Path(__file__).resolve().parent.parent +_HANDLERS_DIR = module_file(__file__).parent.parent CHROMA_SUBPROCESS_SCRIPT = _HANDLERS_DIR / "storage" / "chroma_subprocess.py" EMBED_SUBPROCESS_SCRIPT = _HANDLERS_DIR / "vector" / "embed_subprocess.py" # Memory venv python — auto-detect from memory/.venv/ or use env var override -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] _MEMORY_VENV_PYTHON = _MEMORY_ROOT / ".venv" / "bin" / "python" diff --git a/src/aipass/memory/apps/handlers/storage/chroma_subprocess.py b/src/aipass/memory/apps/handlers/storage/chroma_subprocess.py index 0fc2b5305..fe8c95dc1 100755 --- a/src/aipass/memory/apps/handlers/storage/chroma_subprocess.py +++ b/src/aipass/memory/apps/handlers/storage/chroma_subprocess.py @@ -24,6 +24,7 @@ import logging import hashlib from pathlib import Path +from aipass.memory.apps.handlers.repo_root import module_file logger = logging.getLogger(__name__) @@ -33,14 +34,14 @@ # ============================================================================= # Default global chroma path: memory/.chroma -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] _DEFAULT_DB_PATH = _MEMORY_ROOT / ".chroma" # Sibling embedder script -- same venv, invoked by path. Encoding lives behind # this handler on purpose: a caller that picks its own embedding model can put # vectors from two models in one collection, which fails silently rather than # loudly (no error, just wrong neighbours). The store owns the model choice. -_EMBED_SCRIPT = Path(__file__).resolve().parent.parent / "vector" / "embed_subprocess.py" +_EMBED_SCRIPT = module_file(__file__).parent.parent / "vector" / "embed_subprocess.py" # Singleton clients per path _clients = {} diff --git a/src/aipass/memory/apps/handlers/templates/push_report.py b/src/aipass/memory/apps/handlers/templates/push_report.py index e26020591..059970d16 100644 --- a/src/aipass/memory/apps/handlers/templates/push_report.py +++ b/src/aipass/memory/apps/handlers/templates/push_report.py @@ -30,13 +30,13 @@ """ from datetime import datetime -from pathlib import Path from typing import List from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler +from aipass.memory.apps.handlers.repo_root import module_file -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] REPORTS_DIR = _MEMORY_ROOT / "artifacts" / "push_reports" # Per-entry detail is capped per branch; the remainder is COUNTED, never diff --git a/src/aipass/memory/apps/handlers/templates/push_store.py b/src/aipass/memory/apps/handlers/templates/push_store.py index 54f16236c..673926243 100644 --- a/src/aipass/memory/apps/handlers/templates/push_store.py +++ b/src/aipass/memory/apps/handlers/templates/push_store.py @@ -23,15 +23,15 @@ import os import subprocess import sys -from pathlib import Path from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler +from aipass.memory.apps.handlers.repo_root import module_file -_HANDLERS_DIR = Path(__file__).resolve().parents[1] +_HANDLERS_DIR = module_file(__file__).parents[1] CHROMA_SUBPROCESS_SCRIPT = _HANDLERS_DIR / "storage" / "chroma_subprocess.py" -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] def _memory_python() -> str: diff --git a/src/aipass/memory/apps/handlers/templates/receipt.py b/src/aipass/memory/apps/handlers/templates/receipt.py index 830221c96..5067523bc 100644 --- a/src/aipass/memory/apps/handlers/templates/receipt.py +++ b/src/aipass/memory/apps/handlers/templates/receipt.py @@ -37,6 +37,7 @@ from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler +from aipass.memory.apps.handlers.repo_root import module_file RECEIPT_NAME = ".template_version.json" @@ -46,7 +47,7 @@ STAMPED_BY_RESET = "reset" _SANCTIONED_LANES = (STAMPED_BY_PUSH, STAMPED_BY_BIRTH, STAMPED_BY_RESET) -_TEMPLATES_DIR = Path(__file__).resolve().parents[3] / "templates" +_TEMPLATES_DIR = module_file(__file__).parents[3] / "templates" _GOLD_SOURCE = { "local": "LOCAL.template.json", "observations": "OBSERVATIONS.template.json", diff --git a/src/aipass/memory/apps/handlers/templates/spawn_pusher.py b/src/aipass/memory/apps/handlers/templates/spawn_pusher.py index 87d23ce66..b45283d9d 100644 --- a/src/aipass/memory/apps/handlers/templates/spawn_pusher.py +++ b/src/aipass/memory/apps/handlers/templates/spawn_pusher.py @@ -28,15 +28,17 @@ from pathlib import Path from typing import Any, Dict, List +from aipass.memory.apps.handlers import repo_root from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler +from aipass.memory.apps.handlers.repo_root import module_file # ============================================================================= # PATH SETUP # ============================================================================= # handlers/templates/spawn_pusher.py -> apps/handlers/templates/ (3 levels up = memory/) -MEMORY_ROOT = Path(__file__).resolve().parent.parent.parent.parent +MEMORY_ROOT = module_file(__file__).parent.parent.parent.parent TEMPLATES_DIR = MEMORY_ROOT / "templates" LOCAL_TEMPLATE_PATH = TEMPLATES_DIR / "LOCAL.template.json" @@ -44,12 +46,17 @@ def _find_repo_root() -> Path: - """Walk up from this file to find repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Repo root for this lane — resolved by ``handlers/repo_root.py``. + + Kept as a local name because callers and tests patch it here. The body is a + delegation on purpose: this function used to be one of ten byte-identical + copies, so the first cure landed on one file and CI went red on the next. + + Returns: + The directory holding AIPASS_REGISTRY.json, or the source tree. Never + the process working directory. + """ + return repo_root.find_repo_root(caller="spawn_pusher") def _find_spawn_templates_dir() -> Path: diff --git a/src/aipass/memory/apps/handlers/templates/template_bump.py b/src/aipass/memory/apps/handlers/templates/template_bump.py index 33068b85b..1d52a4e73 100644 --- a/src/aipass/memory/apps/handlers/templates/template_bump.py +++ b/src/aipass/memory/apps/handlers/templates/template_bump.py @@ -60,8 +60,9 @@ from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler from aipass.memory.apps.handlers.templates import receipt +from aipass.memory.apps.handlers.repo_root import module_file -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] _TEMPLATES_DIR = _MEMORY_ROOT / "templates" LEDGER_NAME = ".template_version.json" diff --git a/src/aipass/memory/apps/handlers/templates/trinity_push.py b/src/aipass/memory/apps/handlers/templates/trinity_push.py index 7d0cd475b..7a39bf522 100644 --- a/src/aipass/memory/apps/handlers/templates/trinity_push.py +++ b/src/aipass/memory/apps/handlers/templates/trinity_push.py @@ -78,6 +78,7 @@ from pathlib import Path from typing import Any +from aipass.memory.apps.handlers import repo_root from aipass.prax import logger from aipass.memory.apps.handlers.json import json_handler from aipass.memory.apps.handlers.json import config_loader @@ -86,22 +87,28 @@ from aipass.memory.apps.handlers.json.memory_files import read_memory_file_data, write_memory_file_simple from aipass.memory.apps.handlers.templates import receipt from aipass.memory.apps.handlers.tracking import tab_renderer +from aipass.memory.apps.handlers.repo_root import module_file # ============================================================================= # PATHS # ============================================================================= -_MEMORY_ROOT = Path(__file__).resolve().parents[3] +_MEMORY_ROOT = module_file(__file__).parents[3] _TEMPLATES_DIR = _MEMORY_ROOT / "templates" def _find_repo_root() -> Path: - """Walk up from this file to the repo root (the dir holding AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Repo root for this lane — resolved by ``handlers/repo_root.py``. + + Kept as a local name because callers and tests patch it here. The body is a + delegation on purpose: this function used to be one of ten byte-identical + copies, so the first cure landed on one file and CI went red on the next. + + Returns: + The directory holding AIPASS_REGISTRY.json, or the source tree. Never + the process working directory. + """ + return repo_root.find_repo_root(caller="trinity_push") _REPO_ROOT = _find_repo_root() @@ -302,7 +309,18 @@ def resolve_scope(branch: str | None = None) -> dict: # copy did no residency classification at all, so the push would have kept # sweeping projects by the retired rules while every other lane read the # passport. Same list, same order, one place. - branches = registry_scope.fleet_branches(_REPO_ROOT, name_from="path") + # ...and narrowed again 2026-08-30 (FPLAN-0460 phase 4). registry_scope + # 3.0.0 added the external tier, so `fleet_branches` now answers with + # citizens living in OTHER repositories. Every other consumer of that list + # reads; this lane WRITES template files into each branch it resolves. The + # external tier build never writes outside this repo and the push does not + # get to be the exception, so the scope stops at the repo edge here — at the + # one consumer that acts on the list, not in the reader that serves them all. + branches = [ + item + for item in registry_scope.fleet_branches(_REPO_ROOT, name_from="path") + if item.get("residency") != registry_scope.RESIDENCY_EXTERNAL + ] if branch is None: return {"branches": branches, "error": None} diff --git a/src/aipass/memory/apps/handlers/tracking/tab_renderer.py b/src/aipass/memory/apps/handlers/tracking/tab_renderer.py index c3e831bd9..54e0af018 100644 --- a/src/aipass/memory/apps/handlers/tracking/tab_renderer.py +++ b/src/aipass/memory/apps/handlers/tracking/tab_renderer.py @@ -24,13 +24,13 @@ """ import json -from pathlib import Path from typing import Any, Dict from aipass.prax.apps.modules.logger import get_system_logger from aipass.memory.apps.handlers.json import json_handler from aipass.memory.apps.handlers.json import config_loader from aipass.memory.apps.handlers.json import entry_limits +from aipass.memory.apps.handlers.repo_root import module_file logger = get_system_logger() @@ -46,7 +46,7 @@ # disease the one-source rule exists to cure — the constants are retired and # the text is read from the template at render time. -_TEMPLATES_DIR = Path(__file__).resolve().parents[3] / "templates" +_TEMPLATES_DIR = module_file(__file__).parents[3] / "templates" _TEMPLATE_FILES = { "local": "LOCAL.template.json", diff --git a/src/aipass/memory/apps/modules/fleet.py b/src/aipass/memory/apps/modules/fleet.py new file mode 100644 index 000000000..46a2dcbbe --- /dev/null +++ b/src/aipass/memory/apps/modules/fleet.py @@ -0,0 +1,132 @@ +# =================== AIPass ==================== +# Name: fleet.py +# Description: Fleet definition module — the public cross-branch gateway +# Version: 2.0.0 +# Created: 2026-08-30 +# Modified: 2026-08-30 +# ============================================= + +""" +Fleet Definition Module — Public API + +Who is in the fleet, answered once. The definition itself lives in +``apps/handlers/monitor/registry_scope.py`` and stays there; this module is the +DOOR to it. + +WHY IT EXISTS. ``apps/handlers/`` is private implementation. @seedgo's +``handlers_check.py`` states the rule directly — another branch's ``modules`` +package is its PUBLIC GATEWAY, and ``check_handler_independence`` sends +cross-branch callers there — so the handler import I sanctioned for @daemon +failed both encapsulation and handlers on their first checklist run. They +reported it rather than shimming around it (dispatch 2a70bbcd), which was the +right call: a gateway living in @daemon would be a second public surface for MY +module in a branch I do not control, and the next consumer would import theirs +or write a third. That is the "implementations agreeing by coincidence" failure +in a new costume, which is the exact thing ``registry_scope`` 2.0.0 was built to +end. + +``apps/modules/health.py`` is the same pattern, built for the same consumer. + +WHAT THIS IS NOT. Not a wrapper, not a copy, not a compatibility layer. Every +name below is re-exported by IDENTITY, so a caller here and a caller inside this +branch run the same function object. A gateway that computed anything would be +the second definition it exists to prevent. + +WHAT IS DELIBERATELY BEHIND THE DOOR. ``resident_registry_paths`` and +``read_registry_branches`` are the mechanics of HOW residents are found, not the +question of who is in the fleet. They stop being the whole story the moment the +external tier lands, so pinning a consumer to them would make a routine change +a breaking one. + +THE COMMAND SURFACE IS INTROSPECTION ONLY. I first built this with no +``handle_command`` at all, reasoning that a library gateway should stay +invisible to ``apps/memory.py``'s module discovery. The branch's own convention +said otherwise — ``health.py``, the gateway built for the same consumer, is +discoverable and answers introspection, and three @seedgo standards agree with +it. So this one does the same: ``drone @memory fleet`` describes the contract +and nothing here executes fleet work from the CLI. + +USAGE (cross-branch):: + + from aipass.memory.apps.modules import fleet + + for citizen in fleet.fleet_branches(): + ... # citizen["name"], citizen["email"], citizen["path"] + +Import the MODULE, not the symbols: it keeps refusals and logging attributable +to @memory, and a signature change then surfaces as an ``AttributeError`` at the +call site instead of a wrong answer downstream. +""" + +from aipass.prax import logger + +from aipass.memory.apps.handlers.json import json_handler +from aipass.memory.apps.handlers.monitor.registry_scope import ( + DECLARED_ROOTS, + RESIDENCY_CORE, + RESIDENCY_EXTERNAL, + RESIDENCY_RESIDENT, + accepted_resident_paths, + declared_residency, + declared_roots, + external_branches, + find_repo_root, + fleet_branches, +) + +__all__ = [ + "fleet_branches", + "find_repo_root", + "declared_residency", + "accepted_resident_paths", + "declared_roots", + "external_branches", + "RESIDENCY_CORE", + "RESIDENCY_RESIDENT", + "RESIDENCY_EXTERNAL", + "DECLARED_ROOTS", +] + + +def print_introspection() -> None: + """Display module introspection (seedgo standard).""" + from aipass.cli.apps.modules import console + + console.print() + console.print("[bold cyan]fleet Module[/bold cyan]") + console.print("The fleet definition — who is a citizen, answered once and owned by @memory") + console.print() + console.print("[yellow]Public API:[/yellow]") + console.print(" fleet_branches(repo_root=None, name_from='path') -> every citizen in scope") + console.print(" find_repo_root(start=None) -> the repo anchor") + console.print(" declared_residency(branch_path) -> what a passport declares") + console.print(" accepted_resident_paths(repo_root=None) -> resolved resident paths") + console.print(" declared_roots(repo_root=None) -> participating repo roots") + console.print(" external_branches(repo_root=None) -> citizens outside this repo") + console.print(" RESIDENCY_CORE / RESIDENT / EXTERNAL -> the three tier labels") + console.print(f" DECLARED_ROOTS -> '{DECLARED_ROOTS}', the machine anchor") + console.print() + console.print("[dim]Library module — import from: aipass.memory.apps.modules.fleet[/dim]") + + +def handle_command(command: str, args: list) -> bool: + """Entry point for drone module discovery — fleet has no CLI work, only introspection.""" + if command != "fleet": + return False + + json_handler.log_operation("fleet_command", {"args": args}) + logger.debug(f"[fleet] introspection requested with args={args}") + + if not args: + print_introspection() + return True + + if args[0] in ("--help", "-h", "help"): + print_introspection() + return True + + from aipass.cli.apps.modules import warning + + warning(f"fleet: unknown subcommand '{args[0]}'") + print_introspection() + return True diff --git a/src/aipass/memory/apps/modules/rollover.py b/src/aipass/memory/apps/modules/rollover.py index 9319801cf..ec629e508 100755 --- a/src/aipass/memory/apps/modules/rollover.py +++ b/src/aipass/memory/apps/modules/rollover.py @@ -23,7 +23,6 @@ import json import os import sys -from pathlib import Path from typing import List, NamedTuple if sys.platform == "win32": @@ -52,6 +51,7 @@ execute_rollover as _handler_execute_rollover, sync_line_counts as _handler_sync_line_counts, ) +from aipass.memory.apps.handlers.repo_root import module_file # ============================================================================= @@ -933,8 +933,19 @@ def run_rollover() -> bool: failed = result.get("failed", []) console.print() - if success_count > 0: - console.print(f"[green]>[/green] Rollover complete: {success_count}/{triggers_count} successful") + # 0/N is a result, not a reason to say nothing. Gated on success_count > 0, + # a run where every trigger failed ended on a blank line under "Found N + # files ready for rollover" — the same shape on screen as a run with + # nothing to do. The per-failure detail below says what broke; this says + # what the run achieved. + marker = "[green]>[/green]" if success_count else "[yellow]>[/yellow]" + console.print(f"{marker} Rollover complete: {success_count}/{triggers_count} successful") + + # A trigger the extractor declined is neither a win nor a break, and printing + # neither left "0/1 successful" above an empty failure list — a count saying + # something went wrong beside a list saying nothing did. + for item in result.get("skipped", []): + console.print(f" [yellow]-[/yellow] {item['trigger']} skipped: {item['reason']}") if failed: console.print() @@ -980,7 +991,18 @@ def _normalize_rolled(rolled: List[str]) -> None: wanted = {name.lower() for name in rolled} try: config = config_loader.load() - targets = [item for item in registry_scope.fleet_branches() if item["name"].lower() in wanted] + # STOPS AT THE REPO EDGE, like the push and the rollover detector. + # normalize_branch WRITES — it re-renders the machine frame in place — + # and this lane matches rolled branches BY NAME across whatever scope it + # is given. Handed the whole fleet it would, the day any external + # project names a branch `api` or `flow`, rewrite a file in a sibling + # repository because one of ours happened to roll. There is no collision + # today; that is luck, and luck is not a scope. + targets = [ + item + for item in registry_scope.fleet_branches() + if item["name"].lower() in wanted and item.get("residency") != registry_scope.RESIDENCY_EXTERNAL + ] except Exception as e: logger.warning(f"[rollover] Frame normalize skipped — cannot resolve scope: {e}") return @@ -988,8 +1010,19 @@ def _normalize_rolled(rolled: List[str]) -> None: healed = 0 for item in targets: try: - if normalizer.normalize_branch(item["name"], item["path"], config)["success"]: + # The normalizer reports failure by RETURN VALUE and promises never + # to raise, so this `except` cannot be the thing that catches a + # failed write — reading only ["success"] dropped the error string + # it hands back. A branch whose frame could not be written was + # invisible, while an out-of-scope branch below is named by name. + outcome = normalizer.normalize_branch(item["name"], item["path"], config) + if outcome["success"]: healed += 1 + else: + logger.warning( + f"[rollover] Frame NOT re-rendered for {item['name']}: " + f"{outcome.get('error') or 'nothing could be written'}" + ) except Exception as e: logger.warning(f"[rollover] Frame normalize failed for {item['name']}: {e}") @@ -1251,7 +1284,7 @@ def _discover_handlers() -> dict[str, list[str]]: Dict mapping handler directory name to list of .py filenames (excluding __init__.py and __pycache__). """ - handlers_dir = Path(__file__).resolve().parent.parent / "handlers" + handlers_dir = module_file(__file__).parent.parent / "handlers" result: dict[str, list[str]] = {} if not handlers_dir.exists(): return result diff --git a/src/aipass/memory/apps/modules/roots.py b/src/aipass/memory/apps/modules/roots.py new file mode 100644 index 000000000..c4d688d5d --- /dev/null +++ b/src/aipass/memory/apps/modules/roots.py @@ -0,0 +1,147 @@ +# =================== AIPass ==================== +# Name: roots.py +# Description: Declared-roots module — the operator lane for AIPASS_ROOTS.json +# Version: 1.0.0 +# Created: 2026-08-30 +# Modified: 2026-08-30 +# ============================================= + +""" +Declared Roots Module — the operator lane for the fleet anchor + +``drone @memory roots init | add | remove | list | heal`` + +WHY NOT ON ``modules/fleet.py``. That module is the cross-branch library +gateway, and it carries a test asserting its command surface DESCRIBES the +contract and computes nothing — a gateway whose CLI starts doing work is a +second public surface. These verbs are the opposite job: an operator writing a +machine-managed file. Different job, different module. + +WHY VERBS AT ALL. Patrick's ruling, on seeing a hand-made AIPASS_ROOTS.json: +"jsons are normally created by code, so if they corrupt or get deleted they are +always rebuilt from default settings from a template directory." The verbs are +how a declaration gets made without anyone opening an editor, and they refuse at +WRITE time exactly what the reader refuses at READ time — against the reader's +own predicate, not a copy of it — so the file cannot carry a row that will be +silently dropped. + +``heal`` is deliberate and never automatic; see ``handlers/monitor/roots_file.py`` +for the ruling and its reasoning. +""" + +from aipass.prax import logger + +from aipass.memory.apps.handlers.json import json_handler +from aipass.memory.apps.handlers.monitor import roots_file + + +def _report(ok: bool, message: str) -> None: + from aipass.cli.apps.modules import console, warning + + if ok: + console.print(f"[green]roots:[/green] {message}") + else: + warning(f"roots: {message}") + + +def show_roots() -> None: + """Print every declared row and what it actually resolves to.""" + from aipass.cli.apps.modules import console + + rows = roots_file.list_roots() + if not rows: + console.print(f"[dim]no roots declared ({roots_file.ROOTS_FILE} absent or empty)[/dim]") + return + console.print() + console.print(f"[bold cyan]{roots_file.ROOTS_FILE}[/bold cyan]") + for row in rows: + # UNREACHABLE is the reason this verb exists: in the file a dead row + # looks exactly like a live one, and the only other place it shows up + # is a log line nobody reads. + mark = "[green]reachable[/green]" if row["reachable"] else "[yellow]UNREACHABLE[/yellow]" + console.print(f" {row['path']:<24} {row['label']:<18} {row['status']:<10} {mark}") + if not row["reachable"]: + console.print(f" [dim]resolves to {row['resolves']} — the reader will refuse this row[/dim]") + console.print() + + +def print_introspection() -> None: + """Display module introspection (seedgo standard).""" + from aipass.cli.apps.modules import console + + console.print() + console.print("[bold cyan]roots Module[/bold cyan]") + console.print("The declared-roots anchor — which repo roots on this machine join the fleet") + console.print() + console.print("[yellow]Commands:[/yellow]") + console.print(" roots init render AIPASS_ROOTS.json from the template (refuses if present)") + console.print(" roots add PATH [LABEL] declare a root, validated at write time") + console.print(" roots remove PATH retire a declaration") + console.print(" roots list every row, and whether the reader can use it") + console.print(" roots heal rebuild a corrupt file as an EMPTY scaffold, loudly") + console.print() + console.print("[dim]heal never restores declarations — a rebuild that repopulates has re-declared[/dim]") + console.print(f"[dim]template: {roots_file.template_path()}[/dim]") + + +def handle_command(command: str, args: list) -> bool: + """Entry point for drone module discovery.""" + if command != "roots": + return False + + json_handler.log_operation("roots_command", {"args": args}) + + if not args: + print_introspection() + return True + + # Help anywhere, not only in first position: `roots add --help` is what a + # person actually types when they cannot remember the argument order, and a + # gate that reads args[0] alone would treat it as a path and refuse. + if any(token in ("--help", "-h", "help") for token in args): + print_introspection() + return True + + verb, rest = args[0], args[1:] + + if verb == "list": + show_roots() + return True + + if verb == "init": + _report(*roots_file.init_roots()) + return True + + if verb == "add": + if not rest: + _report(False, "add needs a path: roots add ../wren [label]") + return True + _report(*roots_file.add_root(raw_path=rest[0], label=rest[1] if len(rest) > 1 else "")) + return True + + if verb == "remove": + if not rest: + _report(False, "remove needs a path: roots remove ../wren") + return True + _report(*roots_file.remove_root(raw_path=rest[0])) + return True + + if verb == "heal": + ok, message, salvaged = roots_file.heal() + _report(ok, message) + if salvaged: + from aipass.cli.apps.modules import console, warning + + # Printed, never written back. A salvaged path is a guess about + # intent, and a guess that installs itself is a declaration. The + # heading goes to stderr because it is a warning; the commands go to + # stdout so they can be piped straight into a shell. + warning("declarations NOT restored — re-add them yourself:") + for path in salvaged: + console.print(f" drone @memory roots add {path}") + return True + + logger.info(f"[roots] Unknown subcommand {verb!r}") + _report(False, f"unknown subcommand '{verb}'") + print_introspection() + return True diff --git a/src/aipass/memory/apps/modules/search.py b/src/aipass/memory/apps/modules/search.py index 155fb05ac..7c283b551 100755 --- a/src/aipass/memory/apps/modules/search.py +++ b/src/aipass/memory/apps/modules/search.py @@ -21,7 +21,6 @@ import os import sys -from pathlib import Path from typing import List if sys.platform == "win32": @@ -47,6 +46,7 @@ from aipass.memory.apps.handlers.search.query_executor import ( execute_search as _handler_execute_search, ) +from aipass.memory.apps.handlers.repo_root import module_file # ============================================================================= @@ -297,7 +297,7 @@ def _discover_handlers() -> dict[str, list[str]]: Dict mapping handler directory name to list of .py filenames (excluding __init__.py and __pycache__). """ - handlers_dir = Path(__file__).resolve().parent.parent / "handlers" + handlers_dir = module_file(__file__).parent.parent / "handlers" result: dict[str, list[str]] = {} if not handlers_dir.exists(): return result diff --git a/src/aipass/memory/apps/modules/verify.py b/src/aipass/memory/apps/modules/verify.py index a71ad5c07..7f1e921e4 100644 --- a/src/aipass/memory/apps/modules/verify.py +++ b/src/aipass/memory/apps/modules/verify.py @@ -20,7 +20,6 @@ import json import os import sys -from pathlib import Path from typing import List if sys.platform == "win32": @@ -34,17 +33,18 @@ from aipass.cli.apps.modules import console, error, warning from aipass.memory.apps.handlers.json import json_handler from aipass.memory.apps.handlers.cli.help_flags import wants_help +from aipass.memory.apps.handlers.repo_root import module_file # ============================================================================= # INFRASTRUCTURE SETUP # ============================================================================= # Subprocess script for ChromaDB operations (run in memory venv) -_HANDLERS_DIR = Path(__file__).resolve().parent.parent / "handlers" +_HANDLERS_DIR = module_file(__file__).parent.parent / "handlers" CHROMA_SUBPROCESS_SCRIPT = _HANDLERS_DIR / "storage" / "chroma_subprocess.py" # Memory venv python -- auto-detect from memory/.venv/ or use env var override -_MEMORY_ROOT = Path(__file__).resolve().parents[2] +_MEMORY_ROOT = module_file(__file__).parents[2] _MEMORY_VENV_PYTHON = _MEMORY_ROOT / ".venv" / "bin" / "python" @@ -211,7 +211,7 @@ def _discover_handlers() -> dict[str, list[str]]: Dict mapping handler directory name to list of .py filenames (excluding __init__.py and __pycache__). """ - handlers_dir = Path(__file__).resolve().parent.parent / "handlers" + handlers_dir = module_file(__file__).parent.parent / "handlers" result: dict[str, list[str]] = {} if not handlers_dir.exists(): return result diff --git a/src/aipass/memory/templates/AIPASS_ROOTS.template.json b/src/aipass/memory/templates/AIPASS_ROOTS.template.json new file mode 100644 index 000000000..88094cd25 --- /dev/null +++ b/src/aipass/memory/templates/AIPASS_ROOTS.template.json @@ -0,0 +1,12 @@ +{ + "metadata": { + "document_type": "declared_roots", + "document_name": "AIPASS_ROOTS", + "version": "1.0.0", + "description": "Repo roots participating in this AIPass installation. Machine-managed.", + "last_updated": "{{DATE}}", + "managed_by": "memory", + "_usage": "The anchor for the external tier: AIPass home DECLARES which repo roots on this machine take part. Never a filesystem walk - a root nobody wrote down is not a root, and Patrick blessing this file is the authority a walk could never attach. Write through the verbs (drone @memory roots init|add|remove|list|heal), never by hand: the verbs refuse at WRITE time exactly what the reader refuses at READ time, so this file cannot carry a declaration that will be silently dropped. Rows: path (relative to this file's directory, absolute accepted) - label (decoration, nothing resolves by it) - status (anything but 'active' is skipped and named)." + }, + "roots": [] +} diff --git a/src/aipass/memory/tests/conftest.py b/src/aipass/memory/tests/conftest.py index 6524a158f..53055ca5c 100644 --- a/src/aipass/memory/tests/conftest.py +++ b/src/aipass/memory/tests/conftest.py @@ -32,7 +32,12 @@ def _mock_infrastructure(monkeypatch): # Mock prax logger mock_logger = MagicMock() - prax_mod = MagicMock() + # A stand-in at a PACKAGE name must answer __path__, exactly as the json + # stand-in below does and for the same reason. This one sat six lines above + # that paragraph, unfixed, because the pin naming the rule named a constant + # instead of a shape — see test_import_isolation.py. + prax_mod = ModuleType("aipass.prax") + prax_mod.__path__ = [str(Path(__file__).resolve().parents[2] / "prax")] prax_mod.logger = mock_logger prax_modules_mod = MagicMock() prax_modules_mod.logger = MagicMock() @@ -217,8 +222,11 @@ def live_fleet(): from aipass.memory.apps.handlers.templates import trinity_push for root in (registry_scope.REPO_ROOT, trinity_push._REPO_ROOT): - if not (root / registry_scope.CORE_REGISTRY).is_file(): + registry = root / registry_scope.CORE_REGISTRY + if not registry.is_file(): pytest.skip(f"no {registry_scope.CORE_REGISTRY} at {root} -- live-state guard skipped") + if not _registry_branch_rows(registry): + pytest.skip(f"{registry_scope.CORE_REGISTRY} at {root} has no branch rows -- live-state guard skipped") return registry_scope.REPO_ROOT @@ -268,3 +276,177 @@ def live_residents(live_fleet): f"not installed at {live_fleet} ({', '.join(missing)}) -- live-state guard skipped" ) return live_fleet + + +@pytest.fixture +def live_all_tiers(live_residents): + """A repo root carrying ALL THREE tiers — core, resident AND external — or SKIP. + + `live_fleet` answers "is aipass installed here", `live_residents` adds "are + the four resident projects on disk". Neither says anything about the + external tier, and a test asserting on all three needs all three. + + CI on PR#750 is why this exists. A bare ubuntu checkout ships no registry + and no `AIPASS_ROOTS.json` (both gitignored) and no `projects/`, so every + tier but core resolves empty — and something in the whole-repo run MINTED a + core-only registry mid-run. `live_fleet` asked "is there a registry" and + there was, so the three-tier assertion ran in a one-tier world and reported + the world's shape as a defect in the record. + + EXISTENCE IS NOT SUFFICIENCY. That is the whole lesson, and it applies to + each tier separately: a registry can exist with no rows, a roots anchor can + exist declaring nothing, and a declared root can exist holding no citizen. + Each of those is a half-present world, and each gets its own reason here so + a skip on CI names which tier was missing rather than "guard skipped". + + MEASURED WITH pathlib AND json, never through `registry_scope`. Asking the + resolver whether its own inputs are sufficient would turn every regression + in it into a SKIP — the guard deleting the failure it exists to expose. + `declared_roots()` is the code these tests judge; reading the anchor by hand + is what keeps the guard independent of it. Same argument as the literal + resident paths above, one tier further out. + + Returns: + The repo root carrying all three tiers. + """ + from aipass.memory.apps.handlers.monitor import registry_scope + + anchor = live_residents / registry_scope.DECLARED_ROOTS + if not anchor.is_file(): + pytest.skip( + f"no {registry_scope.DECLARED_ROOTS} at {live_residents} " + f"-- no external tier declared, live-state guard skipped" + ) + + try: + declared = json.loads(anchor.read_text(encoding="utf-8")).get("roots", []) + except (OSError, ValueError, AttributeError) as exc: + pytest.skip(f"unreadable {registry_scope.DECLARED_ROOTS} at {anchor} ({exc}) -- guard skipped") + + if not _an_external_citizen_exists(live_residents, declared): + pytest.skip( + f"{registry_scope.DECLARED_ROOTS} declares {len(declared)} root(s) but no reachable " + f"external citizen -- the third tier is absent, live-state guard skipped" + ) + return live_residents + + +def _registry_branch_rows(registry_path): + """Branch rows in a registry file, or an empty list — NEVER an exception. + + A guard that raises is worse than a guard that skips: it turns "this + machine has no fleet" into a red nobody can act on. Corrupt is absence. + + Both registry shapes are read because both ship: `branches` is a list on + some registries and a name-keyed dict on others, and a guard that knew only + one would call half the fleet empty. + """ + try: + data = json.loads(Path(registry_path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + if not isinstance(data, dict): + return [] + branches = data.get("branches", []) + if isinstance(branches, dict): + return [row for row in branches.values() if isinstance(row, dict)] + if isinstance(branches, list): + return [row for row in branches if isinstance(row, dict)] + return [] + + +def _an_external_citizen_exists(repo_root, declared): + """True when some declared root really holds a citizen, read off disk. + + A declaration is not a citizen — `AIPASS_ROOTS.json` can name four sibling + repositories and every one of them be gone, which on a fresh machine is the + normal case. The tier is present only if a passport is actually reachable + through one of them. + + Deliberately shallow, mirroring the walk law the resolver obeys: a + registry at the root's top level, then that registry's own branches. A + recursive passport hunt here would make the guard find citizens the code + under test would refuse to. + """ + for row in declared: + if not isinstance(row, dict): + continue + raw = row.get("path", "") + if not raw: + continue + root = Path(raw) if Path(raw).is_absolute() else (repo_root / raw) + if not root.is_dir(): + continue + for registry in sorted(root.glob("*_REGISTRY.json")): + for entry in _registry_branch_rows(registry): + branch_path = entry.get("path", "") + if branch_path and (root / branch_path / ".trinity" / "passport.json").is_file(): + return True + return False + + +@pytest.fixture +def case_insensitive_filesystem(monkeypatch): + """Make ``Path.glob`` match the way Windows and macOS match. + + THE CONDITION BEING PINNED IS "the glob returned more than the pattern + spells", not "the test is running on Windows". @drone hit this on the + Windows CI leg — ``*_REGISTRY.json`` also matched a real lowercase file in + their tree — and a skipif here would mean the pin only ever fires on the + one platform where the defect has already shipped. Injecting the widened + match runs the same code path on the Linux dev box, red-first, before CI. + + The emulation is deliberately literal: split the pattern on ``/``, walk one + level per part, compare case-folded. ``fnmatchcase`` on lowered strings + rather than ``fnmatch``, because ``fnmatch`` itself consults the host + platform and would make this fixture a no-op on the box that needs it most. + """ + import fnmatch + + real_glob = Path.glob + + def widened(self, pattern, *args, **kwargs): + if "**" in pattern: + return real_glob(self, pattern, *args, **kwargs) + current = [self] + for part in pattern.split("/"): + nxt = [] + for base in current: + if base.is_dir(): + nxt.extend( + child for child in base.iterdir() if fnmatch.fnmatchcase(child.name.lower(), part.lower()) + ) + current = nxt + return iter(sorted(current)) + + monkeypatch.setattr(Path, "glob", widened) + return widened + + +@pytest.fixture +def case_insensitive_exists(monkeypatch): + """Make ``Path.exists`` answer about a case-folded name, as Windows does. + + @seedgo published this as their own discriminator's blind spot and it is the + worse half of the pair: ``(dir / "AIPASS_REGISTRY.json").exists()`` reads a + lowercase file with no glob in the line to warn a reader. + + The emulation only ever ADDS a True — an exact hit still answers exactly — + so patching it globally cannot break the machinery around the test the way a + replacement implementation would. + """ + import os as _os + + real_exists = Path.exists + + def folded(self, *args, **kwargs): + if real_exists(self, *args, **kwargs): + return True + try: + with _os.scandir(self.parent) as entries: + return any(entry.name.lower() == self.name.lower() for entry in entries) + except OSError: + return False + + monkeypatch.setattr(Path, "exists", folded) + return folded diff --git a/src/aipass/memory/tests/dead_cwd.py b/src/aipass/memory/tests/dead_cwd.py new file mode 100644 index 000000000..c1af1ef9d --- /dev/null +++ b/src/aipass/memory/tests/dead_cwd.py @@ -0,0 +1,298 @@ +# =================== AIPass ==================== +# Name: dead_cwd.py +# Description: The one definition of the dead-cwd world used by this branch's subprocess pins +# Version: 1.5.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""The hostile world, defined once. + +WHY THIS FILE EXISTS +-------------------- +On 2026-08-31 this branch ruled that the condition worth pinning is +``os.getcwd()`` raising, NOT a directory being deleted — deletion is one cause, +and the one Windows makes impossible. The ruling was right and the two copies +of the world that implemented it were not: + +* ``test_repo_root.py`` and ``test_residency_scope.py`` each carried their own + spelling of the denial, and +* both spellings were ``lambda:`` — zero positional arguments. + +CI went red on Python 3.10 only. The mechanism, from @devpulse's traceback: +3.10's ``pathlib`` still has ``_NormalAccessor``, whose ``getcwd`` attribute +captures ``os.getcwd`` AT CLASS-DEFINITION TIME. A plain function stored on a +class becomes a bound method through an instance, so ``Path.cwd()`` reaches it +as ``cls._accessor.getcwd()`` and passes the accessor as ``self`` — one +positional argument, into a zero-argument lambda. ``TypeError``, not +``FileNotFoundError``. 3.11 removed the accessor and calls ``os.getcwd`` +directly, which is why only one version reddened. + +THE SECOND DEFECT, WHICH NOTHING WAS RED ABOUT +---------------------------------------------- +The version difference is not only about arity. Because 3.10 captures +``os.getcwd`` when ``pathlib`` is first imported, a child that imports +``pathlib`` BEFORE installing the denial gets an accessor holding the REAL +``getcwd`` — and the world is not hostile at all. Four of this branch's probes +were written that way. On 3.10 they were passing while asserting nothing, and +no test could have told anyone: a vacuous pin and a cured defect produce the +same green. + +So the world defined here does two things instead of one: + +1. the replacement accepts any arguments, so it answers correctly whether it is + called bare (3.11+) or accessor-bound (3.10), and +2. it patches the accessor itself when one exists, which makes the world + ORDER-INDEPENDENT — a child may import ``pathlib`` before or after and gets + the same hostile world either way. + +Either fix alone would have turned CI green. Both are here because they answer +different questions, and the one CI asked about is not the one that was hiding +the vacuum. + +THE ARITY AND THE ``staticmethod`` ARE REDUNDANT ONLY FOR RAISE-SHAPED PINS, and +that qualifier was missing here until @drone measured it. This paragraph used +to say ``staticmethod``-alone is a surviving equivalent mutant given ``*a``, and +that was true of the suite as it stood: ``*a`` absorbs the ``self`` a bound call +passes, so the world still RAISES for the right reason and every raise-shaped +pin stays green. It stops being true the moment a RETURN-VALUE pin exists — a +bound plain function eats the path into ``*a`` and resolves the accessor object +instead, which a pin that checks what came back can see and a pin that checks +that something was thrown cannot. @drone's tree caught it that way first; this +branch's own return-value pin (added the following round for the realpath +sibling) now kills it here too. Their ``*a``-alone and both-together rows match +these exactly. + +So the sharpened sentence, and it is the one to carry forward: the pair is +redundant for raise-shaped pins and NOT redundant once anything asserts the +value. Recorded because a mutation run that quietly scores the survivor as +killed is lying, and because whichever half a future reader deletes as +"obviously covered" is the one that was covering the other. + +DO NOT ALSO PATCH THE INSTANCE. @spawn suggested the belt-and-braces form — +staticmethod on the class plus a plain function on the instance — and @drone +measured why it is worse than nothing: an instance attribute SHADOWS the class +staticmethod, so the class-level pin can no longer be falsified and the +instrument disarms itself. The class patch is sufficient because 3.10 holds one +shared, attribute-free instance (``pathlib.py:361 _normal_accessor = +_NormalAccessor()``, ``:954 _accessor = _normal_accessor``), so attribute lookup +falls through to the class. Verified against the 3.10 source, not assumed. + +THE RULE THIS IS AN INSTANCE OF, from @spawn on the same night: the injection +must deny the call the DEFECT actually makes. On 3.10 that call is +accessor-bound. A version-shaped world difference is still a world difference. +""" + +# Installed as a source prefix in a subprocess, so it is text rather than code: +# the world has to exist before the module under test is imported, and a +# fixture cannot get in front of that. +DEAD_CWD_WORLD = ( + "import os\n" + "def _dead_cwd(*a, **k):\n" + " raise FileNotFoundError(2, 'No such file or directory')\n" + "os.getcwd = _dead_cwd\n" + "import pathlib as _pathlib\n" + "if hasattr(_pathlib, '_NormalAccessor'):\n" + " _pathlib._NormalAccessor.getcwd = staticmethod(_dead_cwd)\n" +) + +# The other construction: a genuinely deleted working directory. Kept because +# it is the real thing the denial stands in for, and the two are proved to +# agree on POSIX. It cannot be built on Windows, which locks a process's cwd. +DELETE_CWD_WORLD = "import os, tempfile\nd = tempfile.mkdtemp()\nos.chdir(d); os.rmdir(d)\n" + +# What 3.10's pathlib does, reduced to the three lines that matter. Used to +# reproduce the accessor binding on interpreters that no longer have one, so +# the pin has teeth on a laptop running 3.12 instead of only on the CI leg that +# already found the bug. +ACCESSOR_SHAPE = "class _Accessor:\n getcwd = os.getcwd\n_accessor = _Accessor()\n" + +# The OTHER denial, and it is not interchangeable with the one above. +# +# @spawn reproduced CI's ``inspect.stack()`` crash on POSIX on 2026-08-31, +# after this branch had written "on POSIX it cannot crash" into a docstring. +# Verified here before adopting it (see ``TestTheStackReadIsReproducibleAfterAll``). +# +# Denying ``getcwd`` does NOT reach the crash on Linux: ``posixpath.abspath`` +# raises first, inside ``inspect.getabsfile()``, and inspect swallows it — +# ``except (TypeError, FileNotFoundError): return None``. On Windows +# ``ntpath.abspath`` goes through native ``_getfullpathname`` and succeeds, so +# execution continues into ``getmodule``'s loop and dies on the ``realpath`` +# there, which is not inside any try. +# +# Denying ``realpath`` directly lands on that same unprotected call on both +# platforms. It is the injection that denies the call the DEFECT actually +# makes — @spawn's rule, applied to their own finding. +# NEVER CONCATENATE THIS WITH ``DEAD_CWD_WORLD`` ON POSIX. The two denials MASK +# each other there, and the combination is LESS hostile than this one alone — +# measured on CPython 3.12, and independently by @prax while curing the same +# species in their tree: +# +# world posix nt +# getcwd denied SURVIVES DIES +# realpath denied DIES DIES +# getcwd AND realpath denied SURVIVES DIES +# +# THE PLATFORM COLUMN IS NOT DECORATION — this note carried the posix column +# alone until round 7, and stating three Linux measurements as facts about +# ``inspect`` is the exact defect the table below exists to prevent, written +# one comment over from the table. Masking is a ``posixpath`` behaviour. +# +# The outer denial wins because ``getabsfile`` is called INSIDE +# ``except (TypeError, FileNotFoundError)``: deny getcwd and ``abspath`` raises +# there, inspect swallows it and returns None before the unprotected realpath +# is ever reached. More denial is not more hostile — it is a different world, +# and this one happens to be a kinder one. +# +# It is exactly the trap a reader reaching for "let me make the world as hostile +# as possible" falls into, so it is pinned behaviourally in +# ``TestTheTwoWorldsMustNotBeStacked`` rather than left as a comment nobody runs. +REALPATH_DENIED_WORLD = ( + "import os\n" + "def _denied_realpath(*a, **k):\n" + " raise FileNotFoundError(2, 'No such file or directory')\n" + "os.path.realpath = _denied_realpath\n" +) + + +# --------------------------------------------------------------------------- +# The Windows halves, and the version trap that lives in both of them +# --------------------------------------------------------------------------- + +# ``ntpath.realpath`` reads ``os.getcwd()`` on its first lines, before it checks +# whether the path is even absolute — unlike ``posixpath``. So on Windows every +# ``Path(...).resolve()`` is a working-directory read, and that is the condition +# to inject rather than "we are on Windows", which pins nothing runnable. +# +# THE ACCESSOR TRAP, AGAIN, AND THIS TIME IT SHIPPED. The getcwd world above +# carries a long docstring about Python 3.10 capturing ``os.getcwd`` at +# class-definition time. Its sibling — this constant — was written without that +# cure and lived in ``test_repo_root.py``, away from the docstring explaining +# the very trap it was walking into. CI found it on 2026-08-31, Python 3.10 +# only: the arming probe printed NO_RAISE, and every pin underneath it had been +# vacuously green on that interpreter. +# +# MEASURED, NOT INFERRED — CPython 3.10 ``Lib/pathlib.py``:: +# +# 358: realpath = staticmethod(os.path.realpath) # _NormalAccessor +# 1077: s = self._accessor.realpath(self, strict=strict) # Path.resolve +# +# So 3.10's pathlib DOES delegate to ``os.path.realpath``; it just took its copy +# when pathlib was first imported. A probe that imports pathlib and then patches +# ``os.path.realpath`` is patching a name nothing will read again. 3.11 removed +# the accessor and calls ``os.path.realpath`` at use, which is why only one +# version reddened — the same shape, the same version boundary, and the same +# reason as the getcwd world. +# +# Cured the same way and for the same reason: patch the accessor when one +# exists, so the world is ORDER-INDEPENDENT, and take any arguments, because a +# plain function on a class arrives bound. +WINDOWS_REALPATH_WORLD = ( + "import os\n" + "_real_realpath = os.path.realpath\n" + "def _reads_the_cwd(p, *a, **k):\n" + " os.getcwd()\n" + " return _real_realpath(p, *a, **k)\n" + "os.path.realpath = _reads_the_cwd\n" + "import pathlib as _pathlib_rp\n" + "if hasattr(_pathlib_rp, '_NormalAccessor'):\n" + " _pathlib_rp._NormalAccessor.realpath = staticmethod(_reads_the_cwd)\n" +) + +# The PLATFORM, not just the denial. @prax's shape, adopted after CI proved this +# branch had been asserting POSIX facts as universal. +# +# Emulating "the cwd is gone" is not enough to emulate Windows, because the two +# platforms fail at DIFFERENT CALLS. ``ntpath.abspath`` goes through the Win32 +# ``_getfullpathname`` and never touches ``os.getcwd``, so it SURVIVES a getcwd +# denial that kills ``posixpath.abspath``; and ``ntpath.realpath`` reads the cwd +# where posixpath's does not. Both halves have to move or the world is still +# Linux wearing a Windows label. +# +# With both patched, a getcwd denial sails through ``inspect.getabsfile()`` +# (which is where POSIX dies, inside inspect's own ``except``) and arrives at +# the unprotected ``os.path.realpath`` in ``getmodule`` — which is exactly what +# Windows CI measured. +# A DENIAL IS NOT ONE WORLD — READ THIS BEFORE ASSERTING ANY VERDICT +# ------------------------------------------------------------------ +# @prax's sentence, and this branch shipped the bug it describes: "a branch that +# injects a getcwd denial is NOT building one world. On Linux it builds +# POSIX-with-no-cwd; on Windows it builds something strictly more hostile. Any +# pin whose expectation is derived on one and asserted on both will be wrong on +# the other, and it will be wrong in the direction that LOOKS LIKE A PASSING +# TEST." +# +# Measured, both platforms, for ``inspect.stack()``: +# +# world posix nt +# realpath denied DIES DIES +# getcwd denied SURVIVES DIES +# both denied SURVIVES DIES +# +# posix rows measured live on every run; nt rows measured on the Windows runner +# (8550ed10) AND reproducible here inside ``WINDOWS_EMULATED_WORLD``. The table +# is enforced by ``TestTheTwoWorldsMustNotBeStacked``, keyed on ``os.name`` +# because the question is which PATH MODULE the stdlib is using — ``sys.platform`` +# would need a darwin row that behaves exactly like linux. +# +# So: a verdict measured under these worlds is a fact about ONE platform until +# it has been measured on the other or reproduced under the emulation below. + + +# The OTHER half of the same idea, and its absence is what went red next. +# +# Round 6 shipped ``WINDOWS_EMULATED_WORLD`` and left the posix rows running +# with NO prefix at all — "no emulation" reads as "posix" only while the host IS +# posix. On the Windows runner those rows measured nt and reported it under a +# label that said posix. The emulation was one-sided, so the table was +# host-dependent in exactly the half nobody had thought to emulate. +# +# Built from ``posixpath`` EXPLICITLY rather than from ``os.path``, because on +# nt ``os.path`` IS ``ntpath``: an emulation that captures ``os.path.realpath`` +# to build a posix world captures the nt function and emulates nothing. +# +# The two posix facts the table depends on, and both are here: +# * ``abspath`` reads the cwd for a RELATIVE path (which is what a +# pseudo-filename like ```` is), and +# * ``realpath`` does NOT read it for an absolute one. +# +# THE REALPATH HALF IS AN EQUIVALENT MUTANT ON A POSIX HOST, measured rather +# than assumed: deleting it leaves the whole table green here, because once +# ``abspath`` raises inside ``inspect.getabsfile()`` the realpath line is never +# reached, and on Linux the host's own ``posixpath.realpath`` already behaves +# the way the emulation would. It is load-bearing only on an nt host, where it +# is the difference between emulating posix and relabelling nt — which is the +# exact failure this constant exists to prevent, so it stays. Recorded here +# because a mutation run that quietly scores it as killed is lying, and because +# "obviously redundant" is what the next reader will call it. +POSIX_EMULATED_WORLD = ( + "import os, posixpath\n" + "def _posix_abspath(path, *a, **k):\n" + " p = os.fspath(path)\n" + " if not p.startswith('/'):\n" + " p = os.getcwd() + '/' + p\n" + " return posixpath.normpath(p)\n" + "def _posix_realpath(path, *a, **k):\n" + " p = os.fspath(path)\n" + " if not p.startswith('/'):\n" + " p = os.getcwd() + '/' + p\n" + " return posixpath.normpath(p)\n" + "os.path.abspath = _posix_abspath\n" + "os.path.realpath = _posix_realpath\n" +) + + +WINDOWS_EMULATED_WORLD = ( + "import os, posixpath\n" + "_rp_before_emulation = os.path.realpath\n" + "def _win32_abspath(path, *a, **k):\n" + " # _getfullpathname: resolves against the process cwd via Win32, never\n" + " # through os.getcwd, so a Python-level getcwd denial cannot reach it.\n" + " p = os.fspath(path)\n" + " return posixpath.normpath(p if p.startswith('/') else '/emulated_cwd/' + p)\n" + "def _win32_realpath(path, *a, **k):\n" + " os.getcwd() # ntpath.realpath reads it before checking absoluteness\n" + " return _rp_before_emulation(path, *a, **k)\n" + "os.path.abspath = _win32_abspath\n" + "os.path.realpath = _win32_realpath\n" +) diff --git a/src/aipass/memory/tests/test_central_writer.py b/src/aipass/memory/tests/test_central_writer.py index 278b93e9f..1efb340e3 100644 --- a/src/aipass/memory/tests/test_central_writer.py +++ b/src/aipass/memory/tests/test_central_writer.py @@ -437,15 +437,92 @@ def test_failure_returns_error(self, monkeypatch, tmp_path): assert "error" in result def test_logs_operation(self, monkeypatch, tmp_path): - """Should call json_handler.log_operation on success.""" + """Should log update_central exactly once — counted by OPERATION, not by mock. + + THIS PIN WENT RED ON ALL FOUR PYTHON VERSIONS ON CI (2026-08-31, round + 5) and was green on every developer machine, which is the whole lesson. + + ``assert_called_once()`` counted every call on a mock the CONFTEST + installs before import, and ``_import_central_writer`` performs the + import inside the test window. On a bare checkout ``repo_root``'s marker + walk finds no ``AIPASS_REGISTRY.json``, logs a ``repo_root_fallback`` + diagnostic through that same mock, and the count is 2. Production was + behaving exactly as designed; the TEST pinned a number that depended on + whether the machine had a registry above it. + + So the count is now taken over the calls that name THIS operation. That + is the claim the test always meant — update_central logs itself, once — + and it still catches a double-log of update_central, which a bare + ``assert_any_call`` would not. Diagnostics from other lanes ride along + without touching it, which is what a diagnostic should be able to do. + """ cw = _import_central_writer(monkeypatch, tmp_path) mock_handler: MagicMock = sys.modules["aipass.memory.apps.handlers.json"].json_handler cw.update_central() - mock_handler.log_operation.assert_called_once() - call_args = mock_handler.log_operation.call_args - assert call_args[0][0] == "update_central" + ours = [call for call in mock_handler.log_operation.call_args_list if call[0][0] == "update_central"] + assert len(ours) == 1, ( + f"expected exactly one update_central log, got {mock_handler.log_operation.call_args_list}" + ) + + +class TestTheBareWorldTheOldPinCouldNotSurvive: + """CI's world, built here: no marker above the walk, so the fallback fires. + + @devpulse asked for both worlds stated rather than for the red one to be + silenced, and this is the half that was missing. The pin above proves the + count is stable WHEN a diagnostic rides along; this one proves the + diagnostic genuinely rides along and that the write still lands underneath + it. Without it, "counted by operation" would be a claim about a world no + test ever builds. + + Only markers on the REAL checkout are denied. A blunt ``lambda: False`` + was tried first and took 21 unrelated tests down with it — every test that + builds its own marker in ``tmp_path`` and expects the walk to find it. A + world too hostile to be true does not report CI, it reports the probe. + """ + + @staticmethod + def _bare(monkeypatch): + """Deny the marker on the live tree only — prax's hide-the-marker trick.""" + from aipass.memory.apps.handlers import repo_root as rr + + real = rr.exists_exactly + live = rr.SOURCE_ROOT + + def _exists_exactly(path): + try: + Path(path).relative_to(live) + except ValueError: + return real(path) + return False + + monkeypatch.setattr(rr, "exists_exactly", _exists_exactly) + + def test_the_fallback_really_does_fire_in_this_world(self, monkeypatch, tmp_path): + """Arming probe: without this, the test below passes for the wrong reason.""" + self._bare(monkeypatch) + cw = _import_central_writer(monkeypatch, tmp_path) + mock_handler: MagicMock = sys.modules["aipass.memory.apps.handlers.json"].json_handler + + cw.update_central() + + operations = [call[0][0] for call in mock_handler.log_operation.call_args_list] + assert "repo_root_fallback" in operations, ( + "the marker denial did not reach repo_root, so the world below is not CI's: " + str(operations) + ) + + def test_the_write_still_lands_with_the_diagnostic_riding_along(self, monkeypatch, tmp_path): + self._bare(monkeypatch) + cw = _import_central_writer(monkeypatch, tmp_path) + mock_handler: MagicMock = sys.modules["aipass.memory.apps.handlers.json"].json_handler + + result = cw.update_central() + + assert result["success"] is True + ours = [call for call in mock_handler.log_operation.call_args_list if call[0][0] == "update_central"] + assert len(ours) == 1 # =========================================================================== diff --git a/src/aipass/memory/tests/test_changed_entries.py b/src/aipass/memory/tests/test_changed_entries.py index 6bfa29005..a595c64d3 100644 --- a/src/aipass/memory/tests/test_changed_entries.py +++ b/src/aipass/memory/tests/test_changed_entries.py @@ -184,26 +184,29 @@ def test_changed_key_learning_over_cap(self) -> None: class TestUnchangedLegacyFatEntry: - """THE KEY TEST, rewritten 2026-08-27 when the clause was narrowed. - - "Unchanged and over cap passes" was written for a fleet full of legacy - drift. The trinity push cured that fleet-wide, so for an ARCHIVABLE - container the clause now hides new drift rather than protecting old: - an over-cap entry written straight to disk would read as "already there" - on every subsequent write and never surface. Only `todos` keep the - exemption — see TestReshapeOnlyKeepsTheExemption. + """THE KEY TEST, and it has been rewritten twice. + + 2026-08-27 narrowed "unchanged and over cap passes" to `todos`, on the + reasoning that the trinity push had cured drift fleet-wide and the clause + now hid new drift instead of protecting old. + + 2026-08-30 reversed it: drift recurs, and this gate never sees the writes + that cause it (Bash lanes bypass @hooks entirely). Refusing the next write + deadlocked rollover — the one lane whose write is always a shrink. The + entry is now CARRIED: not refused, and no longer silent either. """ - def test_unchanged_500char_key_learning_is_now_flagged(self) -> None: + def test_unchanged_500char_key_learning_is_carried_not_flagged(self) -> None: mod = _get_entry_limits() fat_text = "z" * 500 before = {"key_learnings": {"legacy": fat_text}} after = {"key_learnings": {"legacy": fat_text}} - result = mod.changed_entries(before, after, _KEY_LEARNINGS_ONLY) + assert mod.changed_entries(before, after, _KEY_LEARNINGS_ONLY) == [] - assert len(result) == 1 - assert result[0]["entry_type"] == "key_learnings" + carried = mod.classify_entries(before, after, _KEY_LEARNINGS_ONLY)["carried"] + assert len(carried) == 1 + assert carried[0]["entry_type"] == "key_learnings" class TestReshapeOnlyKeepsTheExemption: @@ -355,16 +358,15 @@ def test_appended_item_over_cap_detected(self) -> None: assert result[0]["key"] == "1" assert result[0]["over_by"] == 100 - def test_existing_unchanged_items_are_now_flagged(self) -> None: - """Post-push, "unchanged" means "written and not yet caught".""" + def test_existing_unchanged_items_are_carried_not_flagged(self) -> None: + """Reversed 2026-08-30: unchanged means "this write did not author it".""" mod = _get_entry_limits() fat_item = {"session_number": 1, "summary": "s" * 400} before = {"sessions": [fat_item]} after = {"sessions": [fat_item]} - result = mod.changed_entries(before, after, _SESSIONS_ONLY) - - assert len(result) == 1 + assert mod.changed_entries(before, after, _SESSIONS_ONLY) == [] + assert len(mod.classify_entries(before, after, _SESSIONS_ONLY)["carried"]) == 1 # =========================================================================== @@ -375,12 +377,14 @@ def test_existing_unchanged_items_are_now_flagged(self) -> None: class TestListPrependIdentityMatch: """Prepending a new entry must NOT re-flag shifted legacy over-cap entries.""" - def test_prepend_reports_the_legacy_entries_it_shifted_past(self) -> None: - """The prepend itself is clean; the five over-cap entries beneath are not. + def test_prepend_carries_the_legacy_entries_it_shifted_past(self) -> None: + """The prepend is clean, and the five it shifted past are not its doing. - Before the 2026-08-27 narrowing this returned nothing, and that silence - was the point of the clause. Now the write is told exactly which five - entries are over cap — the new one is not among them. + Identity is the TEXT, never the index — the whole point. A prepend + moves every legacy entry one slot down; an index-keyed diff would call + all five newly authored and refuse a write that authored one short + entry. They are reported as carried debt instead, and the new entry is + not among them because it is within cap. """ mod = _get_entry_limits() legacy = [{"session_number": i, "summary": "s" * 400} for i in range(5, 0, -1)] @@ -388,11 +392,12 @@ def test_prepend_reports_the_legacy_entries_it_shifted_past(self) -> None: new_entry = {"session_number": 6, "summary": "short new"} after = {"sessions": [new_entry] + legacy} - result = mod.changed_entries(before, after, _SESSIONS_ONLY) + assert mod.changed_entries(before, after, _SESSIONS_ONLY) == [] - assert len(result) == 5 - assert all(hit["entry_type"] == "sessions" for hit in result) - assert "0" not in {hit["key"] for hit in result} + carried = mod.classify_entries(before, after, _SESSIONS_ONLY)["carried"] + assert len(carried) == 5 + assert all(hit["entry_type"] == "sessions" for hit in carried) + assert "0" not in {hit["key"] for hit in carried} def test_edited_existing_entry_text_still_caught(self) -> None: """Changing an existing entry's text to over-cap is still flagged.""" @@ -522,9 +527,15 @@ def test_enforce_rejects_new_fat_entry(self, tmp_path: Path, monkeypatch: pytest class TestEnforceAllowsUnchangedLegacy: - """THE CRITICAL ROLLOVER-SAFE TEST: enforce mode allows writing back same fat data.""" + """THE CRITICAL ROLLOVER-SAFE TEST: enforce mode allows writing back same fat data. + + The class name has been right the whole time; between 2026-08-27 and + 2026-08-30 the method under it asserted the opposite, which is its own + small lesson — a name describing a rule the body no longer holds reads as + proof the rule is there. + """ - def test_enforce_now_refuses_same_data_with_fat_entries( + def test_enforce_allows_same_data_with_fat_entries( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -543,9 +554,10 @@ def test_enforce_now_refuses_same_data_with_fat_entries( result = mem_mod.write_memory_file(local_path, fat_data) - # Narrowed 2026-08-27: key_learnings is archivable, so an over-cap - # entry is refused even when the write did not create it. - assert result["success"] is False + # Reversed 2026-08-30: the write authored nothing, so it is not refused. + # Refusing it cured nothing and deadlocked rollover, whose write is + # always the smaller document. + assert result["success"] is True on_disk = json.loads(local_path.read_text(encoding="utf-8")) assert on_disk["key_learnings"]["legacy"] == "z" * 500 @@ -595,3 +607,429 @@ def test_passport_writes_normally(self, tmp_path: Path) -> None: assert passport_path.exists() written = json.loads(passport_path.read_text(encoding="utf-8")) assert written == data + + +# =========================================================================== +# 13. THE SHRINK-WRITE DEADLOCK (2026-08-30) +# =========================================================================== +# +# @devpulse, via four @trigger repeat-signatures: rollover erroring identically +# every 20 minutes for three hours. The extractor removed the tail, wrote the +# document back, and the write gate refused the WHOLE file for an entry the +# extraction never touched — @seedgo's 343-char summary sitting in the head. +# +# That is a deadlock with the archiver on the losing side: the file cannot get +# smaller because it is too big. The lane that preserves everything else was +# bricked by one entry it is not allowed to shrink. +# +# THE NARROWING THAT CAUSED IT (2026-08-27) reasoned that the trinity push had +# cured drift fleet-wide, so "unchanged and over cap passes" now hid new drift +# instead of protecting old. Both halves were wrong about the world: +# +# - Drift is not cured, it RECURS. @ai_mail carried three over-cap +# key_learnings the same evening; @seedgo one. +# - The write gate is structurally BLIND to how drift arrives. @hooks' +# edit_gate says so in its own refusal text: writes made through Bash +# (python -c, heredoc, sed) never reach it. @baud drifted to 2529/300 for +# a week through that lane. An entry the gate never saw written cannot be +# caught by refusing the next write — it can only be caught by READING THE +# FILE, which is exactly what `drone @memory lint` does, on demand, fleet- +# wide, read-only. +# +# So the narrowing put the detection job on the one component that cannot see +# the drift arrive, and paid for it with rollover. +# +# THE RULE, restored and widened: a write is judged by what it AUTHORS, never +# by what it carries. An entry byte-identical to one already on disk was not +# written by this write and cannot be refused to it. The silence that the +# narrowing rightly objected to is cured where silence was the actual defect — +# carried debt is now REPORTED on every write instead of skipped without a +# word, and `lint` remains the lane that reads disk. +# +# This is one classifier with two labels, not two rules: `changed_entries` +# answers "what did this write author", `carried_entries` answers "what is it +# carrying". Each consumer sets its own policy from the same measurement. + + +_FAT_SUMMARY = "X" * 343 # the @seedgo shape: over the 300-char sessions cap + + +class TestShrinkWriteIsNeverRefused: + """Rollover's write REMOVES entries and authors nothing. It cannot be refused.""" + + def test_removing_the_tail_passes_with_a_fat_entry_in_the_head(self, tmp_path: Path, monkeypatch) -> None: + """The live deadlock, reproduced: 3 sessions in, tail archived, 2 out.""" + mem_mod = _get_memory_files() + + trinity = tmp_path / "seedgo" / ".trinity" + trinity.mkdir(parents=True) + local_path = trinity / "local.json" + + before = { + "sessions": [ + {"number": 3, "summary": _FAT_SUMMARY}, # head — kept, over cap, untouched + {"number": 2, "summary": "within cap"}, + {"number": 1, "summary": "oldest — what rollover archives"}, + ] + } + local_path.write_text(json.dumps(before, indent=2), encoding="utf-8") + + enforce_limits = _full_limits(enforce=True) + monkeypatch.setattr(mem_mod, "load_entry_limits", lambda branch: enforce_limits) + + after = {"sessions": before["sessions"][:2]} # strictly a removal + result = mem_mod.write_memory_file(local_path, after) + + assert result["success"] is True, f"shrink write refused: {result.get('error')}" + assert len(json.loads(local_path.read_text(encoding="utf-8"))["sessions"]) == 2 + + def test_the_carried_entry_is_reported_not_swallowed(self, tmp_path: Path, monkeypatch) -> None: + """Passing silently is what the narrowing was right to object to.""" + mem_mod = _get_memory_files() + mock_logger = mem_mod.logger + + trinity = tmp_path / "seedgo" / ".trinity" + trinity.mkdir(parents=True) + local_path = trinity / "local.json" + before = {"sessions": [{"number": 2, "summary": _FAT_SUMMARY}, {"number": 1, "summary": "old"}]} + local_path.write_text(json.dumps(before, indent=2), encoding="utf-8") + + monkeypatch.setattr(mem_mod, "load_entry_limits", lambda branch: _full_limits(enforce=True)) + + result = mem_mod.write_memory_file(local_path, {"sessions": before["sessions"][:1]}) + + assert result["success"] is True + warnings = " ".join(str(c) for c in mock_logger.warning.call_args_list) + assert "CARRIED" in warnings, "carried debt passed without a word" + assert "343/300" in warnings, "the report does not name the entry" + assert "seedgo" in warnings, "the report does not name the owner" + + +class TestAuthoringIsStillRefused: + """Widening the exemption must not open the door it was narrowed to close.""" + + def test_a_new_over_cap_session_is_refused(self, tmp_path: Path, monkeypatch) -> None: + mem_mod = _get_memory_files() + + trinity = tmp_path / "somebranch" / ".trinity" + trinity.mkdir(parents=True) + local_path = trinity / "local.json" + local_path.write_text(json.dumps({"sessions": [{"number": 1, "summary": "ok"}]}), encoding="utf-8") + + monkeypatch.setattr(mem_mod, "load_entry_limits", lambda branch: _full_limits(enforce=True)) + + after = {"sessions": [{"number": 2, "summary": _FAT_SUMMARY}, {"number": 1, "summary": "ok"}]} + result = mem_mod.write_memory_file(local_path, after) + + assert result["success"] is False + assert "343/300" in result["error"] + + def test_editing_a_carried_entry_and_leaving_it_over_cap_is_refused(self) -> None: + """Touch it and you own it — the exemption covers only byte-identical text.""" + mod = _get_entry_limits() + before = {"sessions": [{"number": 1, "summary": "z" * 500}]} + after = {"sessions": [{"number": 1, "summary": "y" * 400}]} + + result = mod.changed_entries(before, after, _SESSIONS_ONLY) + + assert len(result) == 1 + assert result[0]["over_by"] == 100 + + +class TestCarriedEntries: + """The second label: what the write is carrying, measured from the same diff.""" + + def test_carried_reports_the_untouched_over_cap_entry(self) -> None: + mod = _get_entry_limits() + fat = {"number": 1, "summary": _FAT_SUMMARY} + before = {"sessions": [fat]} + after = {"sessions": [fat]} + + assert mod.changed_entries(before, after, _SESSIONS_ONLY) == [] + carried = mod.classify_entries(before, after, _SESSIONS_ONLY)["carried"] + assert len(carried) == 1 + assert carried[0]["container"] == "sessions" + assert carried[0]["length"] == 343 + assert carried[0]["cap"] == 300 + + def test_an_authored_entry_is_never_also_carried(self) -> None: + """The two labels partition the over-cap set — no entry wears both.""" + mod = _get_entry_limits() + before = {"sessions": [{"number": 1, "summary": "z" * 500}]} + after = { + "sessions": [ + {"number": 2, "summary": _FAT_SUMMARY}, # authored now + {"number": 1, "summary": "z" * 500}, # carried + ] + } + + authored = mod.changed_entries(before, after, _SESSIONS_ONLY) + carried = mod.classify_entries(before, after, _SESSIONS_ONLY)["carried"] + + assert [v["length"] for v in authored] == [343] + assert [v["length"] for v in carried] == [500] + + def test_within_cap_entries_are_not_carried_debt(self) -> None: + mod = _get_entry_limits() + same = {"sessions": [{"number": 1, "summary": "well within"}]} + + assert mod.classify_entries(same, same, _SESSIONS_ONLY)["carried"] == [] + + +class TestRenamedFieldEntryDoesNotBrickRollover: + """An unmeasurable legacy entry is carried, not refused — same deadlock.""" + + def test_a_legacy_renamed_field_entry_is_carried(self) -> None: + """@ai_mail and @api carried `learning` where the config says `value`.""" + mod = _get_entry_limits() + legacy = {"number": 1, "learning": "z" * 500} # canonical field absent + before = {"sessions": [legacy]} + after = {"sessions": [legacy]} + + assert mod.changed_entries(before, after, _SESSIONS_ONLY) == [] + assert len(mod.classify_entries(before, after, _SESSIONS_ONLY)["carried"]) == 1 + + +class TestTheBypassClaimIsMeasuredNotAsserted: + """`.seedgo/bypass.json` claims @hooks calls `changed_entries` — this checks it. + + seedgo's unused_function checker is branch-local, so it correctly cannot see + that this function's only caller lives in another tree. The bypass rule is + the sanctioned way to say so, but a bypass is a CLAIM, and a claim nobody + measures is how a real entry point gets deleted for a green audit two + refactors from now. + + So the justification is pinned. The day @hooks stops calling it, this goes + red and the bypass is provably stale — which is the moment to delete the + function, not before. + + READ-ONLY and TOLERANT OF ABSENCE: it greps @hooks' tree for the call rather + than pinning a line number, and skips where hooks is not installed, because + an external checkout of this branch alone is a real world and not a failure. + """ + + @staticmethod + def _hooks_root(): + """@hooks' source tree, or None when this checkout does not carry it.""" + from aipass.memory.apps.handlers import repo_root + + candidate = repo_root.SOURCE_ROOT / "src" / "aipass" / "hooks" / "apps" + return candidate if candidate.is_dir() else None + + def test_hooks_still_calls_the_function_the_bypass_exempts(self) -> None: + root = self._hooks_root() + if root is None: + pytest.skip("@hooks is not installed in this checkout — nothing to measure") + + callers = [ + path.relative_to(root) + for path in root.rglob("*.py") + if ".archive" not in path.parts and "changed_entries(" in path.read_text(encoding="utf-8") + ] + assert callers, ( + "no file in @hooks calls changed_entries() any more — the unused_function bypass in " + ".seedgo/bypass.json is now a false claim. Either the caller moved (update the reason) " + "or the function is genuinely dead (delete it and drop the bypass)." + ) + + def test_the_bypass_rule_is_narrowed_to_that_one_function(self) -> None: + """A file-wide exemption would hide the NEXT orphan in the same file.""" + import json as _json + from aipass.memory.apps.handlers import repo_root + + rules = _json.loads( + (repo_root.SOURCE_ROOT / "src" / "aipass" / "memory" / ".seedgo" / "bypass.json").read_text( + encoding="utf-8" + ) + )["bypass"] + mine = [ + rule + for rule in rules + if rule.get("standard") == "unused_function" and rule.get("file", "").endswith("entry_limits.py") + ] + assert mine, "the unused_function bypass for entry_limits.py is gone" + assert mine[0].get("functions") == ["changed_entries"], ( + f"the exemption is not narrowed to the one function it was argued for: {mine[0]}" + ) + + +class TestTheNearCapLineArrivesWhileThereIsStillRoom: + """@ai_mail's ask, and their own evidence was the argument for it. + + They wrote over the cap FOUR HOURS after being burned by it — knowing the + number, with it in front of them. Their sentence, which is the design + brief: "nothing in the act of writing shows you the limit — the only + instrument is downstream." A refusal teaches at the moment it is already + too late. This teaches one entry early. + """ + + def test_an_authored_entry_close_to_the_cap_is_reported(self) -> None: + mod = _get_entry_limits() + before = {"key_learnings": {"a": "short"}} + after = {"key_learnings": {"a": "short", "b": "x" * 190}} + + near = mod.classify_entries(before, after, _KEY_LEARNINGS_ONLY)["near"] + + assert [hit["key"] for hit in near] == ["b"] + assert near[0]["length"] == 190 + assert near[0]["cap"] == 200 + + def test_a_comfortable_entry_is_not_reported(self) -> None: + """The signal is only worth having if most writes do not trip it.""" + mod = _get_entry_limits() + after = {"key_learnings": {"a": "x" * 179}} + + assert mod.classify_entries({}, after, _KEY_LEARNINGS_ONLY)["near"] == [] + + def test_the_boundary_is_at_the_ratio_not_past_it(self) -> None: + mod = _get_entry_limits() + after = {"key_learnings": {"exact": "x" * 180}} + + assert len(mod.classify_entries({}, after, _KEY_LEARNINGS_ONLY)["near"]) == 1 + + def test_an_over_cap_entry_is_a_violation_and_never_also_near(self) -> None: + """No entry wears both labels — the same rule the authored/carried split obeys.""" + mod = _get_entry_limits() + after = {"key_learnings": {"fat": "x" * 250}} + + split = mod.classify_entries({}, after, _KEY_LEARNINGS_ONLY) + + assert len(split["authored"]) == 1 + assert split["near"] == [] + + def test_carried_near_cap_text_is_not_reported(self) -> None: + """The discriminator that decides refusals, applied to the softer signal. + + A near-cap entry this write did not author is not this write's business, + and a line on every write about text nobody touched is how a channel + becomes noise nobody reads. + """ + mod = _get_entry_limits() + standing = {"key_learnings": {"old": "x" * 195}} + + split = mod.classify_entries(standing, standing, _KEY_LEARNINGS_ONLY) + + assert split["near"] == [] + assert split["authored"] == [] + + def test_editing_a_near_cap_entry_at_all_reports_it_again(self) -> None: + """Carried is about the TEXT, so touching it makes it authored again.""" + mod = _get_entry_limits() + before = {"key_learnings": {"old": "x" * 195}} + after = {"key_learnings": {"old": "y" * 195}} + + assert len(mod.classify_entries(before, after, _KEY_LEARNINGS_ONLY)["near"]) == 1 + + def test_it_works_on_list_containers_too(self) -> None: + """Both container shapes, because a rule honoured by one is a coincidence.""" + mod = _get_entry_limits() + after = {"sessions": [{"summary": "x" * 275}]} + + near = mod.classify_entries({}, after, _SESSIONS_ONLY)["near"] + + assert [hit["key"] for hit in near] == ["0"] + assert near[0]["cap"] == 300 + + def test_a_type_with_no_cap_has_nothing_to_be_near(self) -> None: + """cap 0 means "no cap configured". Reporting there would flag everything.""" + mod = _get_entry_limits() + + assert mod.is_near_cap({"ok": True, "length": 5000, "cap": 0}) is False + + def test_a_refusal_verdict_is_never_near(self) -> None: + mod = _get_entry_limits() + + assert mod.is_near_cap({"ok": False, "length": 250, "cap": 200}) is False + + def test_the_near_line_names_the_headroom_on_a_real_write(self, tmp_path, monkeypatch) -> None: + """End to end: the warning reaches the log with a number to act on.""" + import logging + + from aipass.memory.apps.handlers.json import memory_files + + branch = tmp_path / "src" / "aipass" / "somebranch" + (branch / ".trinity").mkdir(parents=True) + target = branch / ".trinity" / "local.json" + target.write_text(json.dumps({"key_learnings": {}}), encoding="utf-8") + + monkeypatch.setattr(memory_files, "load_entry_limits", lambda branch_name: _KEY_LEARNINGS_ONLY) + + records: list[str] = [] + monkeypatch.setattr( + memory_files.logger, "warning", lambda message, *a, **k: records.append(str(message)), raising=False + ) + + memory_files._validate_entry_limits(target, {"key_learnings": {"b": "x" * 190}}) + + assert any("NEAR" in line and "190/200" in line and "10 chars of headroom" in line for line in records), records + assert logging # the import is the point: nothing here reconfigures logging + + +class TestTheNearCapLineIsAPerTypeKnob: + """One ratio across four containers was one number doing four jobs. + + @ai_mail asked for the near-cap line, then asked for the fleet number before + accepting its threshold, then read the number and WITHDREW their own follow-up + ask — the measurement said a gate-time warning would fire on well over half + of all edits, and a warning that common teaches people to click past the + refusals beside it. + + What survived their retraction is the sharper observation: the fleet's four + containers fire at 65.0 / 60.5 / 48.6 / 35.8 percent under a single 0.9, and + a distribution with no knee cannot be tuned into a rare signal by moving one + number that all four share. + + So the knob moved to where ``max_chars`` lives rather than being turned here. + Whoever owns the caps owns the warning's tightness for each type, and this + file does not have to pick a number it cannot justify. The module default + stays 0.9 on measured value — it has caught @ai_mail three times and this + branch four in the days it has existed. + + ``check_entry`` publishes ``near_at`` as a CHARACTER COUNT rather than + letting callers re-derive it from a ratio, for the same reason it publishes + ``over_by``: a second implementation of the same arithmetic is a second + chance for the warning and the refusal to disagree about one entry. + """ + + @staticmethod + def _limits(**type_extras): + return {"entry_types": {"notes": {"max_chars": 100, **type_extras}}} + + def test_the_module_default_applies_when_the_type_says_nothing(self): + verdict = _get_entry_limits().check_entry("notes", "x" * 90, self._limits()) + assert verdict["near_at"] == 100 * _get_entry_limits().NEAR_CAP_RATIO + assert _get_entry_limits().is_near_cap(verdict) is True + + def test_a_type_can_ask_for_a_rarer_signal(self): + limits = self._limits(near_cap_ratio=0.97) + assert _get_entry_limits().is_near_cap(_get_entry_limits().check_entry("notes", "x" * 90, limits)) is False + assert _get_entry_limits().is_near_cap(_get_entry_limits().check_entry("notes", "x" * 97, limits)) is True + + def test_a_type_can_ask_for_an_earlier_one(self): + limits = self._limits(near_cap_ratio=0.5) + assert _get_entry_limits().is_near_cap(_get_entry_limits().check_entry("notes", "x" * 50, limits)) is True + + def test_over_cap_is_never_near_cap_whatever_the_ratio(self): + """The labels partition. A refusal must not also arrive as a soft word.""" + limits = self._limits(near_cap_ratio=0.5) + verdict = _get_entry_limits().check_entry("notes", "x" * 101, limits) + assert verdict["ok"] is False + assert _get_entry_limits().is_near_cap(verdict) is False + + def test_a_nonsense_ratio_is_refused_rather_than_obeyed(self): + """A ratio of 0 would silence the line; a ratio of 5 would delete it. + + Both read as configuration and neither is, so the default is used and + the operator is told — silently obeying an out-of-range number is how a + channel goes dark without anyone deciding it should. + """ + for bad in (0, -0.5, 5, "0.9", None): + verdict = _get_entry_limits().check_entry("notes", "x" * 90, self._limits(near_cap_ratio=bad)) + assert verdict["near_at"] == 100 * _get_entry_limits().NEAR_CAP_RATIO, f"obeyed {bad!r}" + + def test_a_verdict_built_before_near_at_existed_still_answers(self): + """The published shape gained a key; a caller holding the old one is not broken.""" + legacy = {"ok": True, "length": 95, "cap": 100, "over_by": 0, "entry_type": "notes"} + assert _get_entry_limits().is_near_cap(legacy) is True + legacy["length"] = 10 + assert _get_entry_limits().is_near_cap(legacy) is False diff --git a/src/aipass/memory/tests/test_declared_roots.py b/src/aipass/memory/tests/test_declared_roots.py new file mode 100644 index 000000000..54d7b696d --- /dev/null +++ b/src/aipass/memory/tests/test_declared_roots.py @@ -0,0 +1,653 @@ +# =================== AIPass ==================== +# Name: test_declared_roots.py +# Description: Pins the declared-roots anchor and the external tier it opens +# Version: 1.0.0 +# Created: 2026-08-30 +# Modified: 2026-08-30 +# ============================================= + +"""Repos outside this one join the fleet by DECLARATION, never by being nearby. + +FPLAN-0460 phase 2. Patrick's ruling, verbatim: "if .daemon is present anywhere +on the machine and an agent exists there, daemon should be available - its really +that simple." @devpulse gave the GO on the mechanism @drone and I reached +independently: AIPass home declares which repo roots participate. + +WHAT THE GO CHANGED ABOUT MY OWN EARLIER RULING. I ruled that passport 2.0 needed +a third ``citizenship.residency`` value before external citizens could be seen. +That ruling made a schema migration and a six-owner declaration campaign into a +PRECONDITION for a working feature, and the GO retired it as one. So membership +here is PRESENCE, not declaration: a branch in a declared root is a citizen if +``.trinity/passport.json`` exists. None of the six live external citizens has a +residency field, and gating on one would have shipped nothing. + +WHAT DID NOT CHANGE, and it is the law this module was built on: DECLARED roots +only, never a walk, at any depth. The reason is measured rather than asserted - a +passport walk of our own ``projects/`` returns eight passports for four +residents, because @baud carries copies under ``.backup/``. The same walk across +a machine would count every snapshot of every repo. + +The two candidate anchors that were REJECTED, both because they had already +failed in production: ``ai_mail``'s contacts.json accretes by last_seen, carries +dead April entries and does not contain @wren at all; and my own +``known_registries.json`` persisted a deleted /tmp scratchpad probe while missing +Vera-Studio's real registry. An anchor that has to be right cannot be one that +accumulates by accident. +""" + +import json +from pathlib import Path + +import pytest + +from aipass.memory.apps.handlers.monitor import registry_scope as rs + + +def _write(path: Path, document: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(document), encoding="utf-8") + + +def _registry(*branches: dict) -> dict: + return {"metadata": {"name": "TEST"}, "branches": list(branches)} + + +def _branch(name: str, path: str, status: str = "active") -> dict: + return {"name": name.upper(), "path": path, "email": f"@{name}", "status": status} + + +def _roots(*rows: dict) -> dict: + return {"metadata": {"version": "1.0.0"}, "roots": list(rows)} + + +def _root_row(path: str, label: str = "x", status: str = "active") -> dict: + return {"path": path, "label": label, "status": status} + + +@pytest.fixture +def machine(tmp_path): + """A synthetic machine: one AIPass repo and three siblings beside it. + + Built on disk rather than mocked, for the same reason the residency fixture + is: this module's entire job is reading real files in a particular + relationship to each other, and a mock of that relationship tests the mock. + """ + home = tmp_path / "AIPass" + _write(home / "AIPASS_REGISTRY.json", _registry(_branch("alpha", "src/aipass/alpha"))) + _write(home / "src/aipass/alpha/.trinity/passport.json", {"citizenship": {"residency": "core"}}) + + # A genuine external repo: own sealed registry, two citizens, one of which + # opts in to the scheduler. NEITHER declares a residency -- that is the + # point of the GO. + wren = tmp_path / "wren" + _write(wren / "WREN_REGISTRY.json", _registry(_branch("wren", "src/wren"), _branch("quiet", "src/quiet"))) + _write(wren / "src/wren/.trinity/passport.json", {"citizenship": {"registered": True}}) + _write(wren / "src/wren/.daemon/schedule.json", {"jobs": []}) + _write(wren / "src/quiet/.trinity/passport.json", {"citizenship": {"registered": True}}) + + # Listed active in its registry and has NO passport: not a citizen. + _write( + wren / "WREN_REGISTRY.json", + _registry(_branch("wren", "src/wren"), _branch("quiet", "src/quiet"), _branch("ghost", "src/ghost")), + ) + + # A second external repo, to prove roots are independent of each other. + demo = tmp_path / "Demo" + _write(demo / "DEMO_REGISTRY.json", _registry(_branch("solo", "src/solo"))) + _write(demo / "src/solo/.trinity/passport.json", {"citizenship": {}}) + + # Never declared. Exists, is a valid repo, and must stay invisible. + undeclared = tmp_path / "Undeclared" + _write(undeclared / "UNDECLARED_REGISTRY.json", _registry(_branch("nobody", "src/nobody"))) + _write(undeclared / "src/nobody/.trinity/passport.json", {"citizenship": {}}) + + return home + + +class TestTheAnchorIsADeclarationNotADiscovery: + """Zero roots is a legal state. A root nobody declared is not a root.""" + + def test_no_roots_file_is_not_an_error(self, machine): + assert rs.declared_roots(machine) == [] + + def test_an_undeclared_sibling_stays_invisible(self, machine): + """The whole point, stated as a test: proximity is not membership.""" + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"))) + found = {path.name for path in rs.declared_roots(machine)} + assert found == {"wren"} + assert (machine.parent / "Undeclared").is_dir(), "the negative case is not on disk to be proven" + + def test_relative_paths_resolve_against_the_repo_root(self, machine): + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"), _root_row("../Demo"))) + assert sorted(path.name for path in rs.declared_roots(machine)) == ["Demo", "wren"] + + def test_an_absolute_path_is_accepted(self, machine): + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row(str(machine.parent / "wren")))) + assert [path.name for path in rs.declared_roots(machine)] == ["wren"] + + def test_a_root_inside_this_repo_is_refused(self, machine): + """The double-count guard, and it is not hypothetical. + + Declaring our own tree as an external root would return every core + citizen and every resident a second time under a different tier. @baud + would appear three times: once as a resident, once here, and once more + through the backup copy the walk law already refuses. + """ + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("."), _root_row("src"), _root_row("../wren"))) + assert [path.name for path in rs.declared_roots(machine)] == ["wren"] + + def test_the_same_root_declared_twice_is_one_root(self, machine): + _write( + machine / rs.DECLARED_ROOTS, + _roots(_root_row("../wren"), _root_row(str(machine.parent / "wren")), _root_row("../wren/")), + ) + assert len(rs.declared_roots(machine)) == 1 + + @pytest.mark.parametrize( + "label, row", + [ + ("retired", {"path": "../wren", "status": "retired"}), + ("no status", {"path": "../wren"}), + ("missing directory", {"path": "../nowhere", "status": "active"}), + ("path is a file", {"path": "../AIPass/AIPASS_REGISTRY.json", "status": "active"}), + ("path is empty", {"path": "", "status": "active"}), + ("path is not a string", {"path": 7, "status": "active"}), + ("row is not an object", "../wren"), + ], + ) + def test_every_unusable_row_is_skipped_and_the_good_one_survives(self, machine, label, row): + """One bad row costs one row. Never the whole file. + + Parametrised with a GOOD row alongside each bad one on purpose: a + reader that returned [] on any defect would pass a test that only + checked the bad row was absent. + """ + _write(machine / rs.DECLARED_ROOTS, _roots(row, _root_row("../Demo"))) + assert [path.name for path in rs.declared_roots(machine)] == ["Demo"] + + +class TestDeclarationOrderSurvivesToTheDoor: + """@ai_mail's abe8141b, answered in code. + + The fleet ruling says an N-root tie is broken by DECLARATION order, but + this reader used to hand back ``sorted(found)`` — alphabetical by resolved + path. @ai_mail found the gap the honest way: they reported that the order + reaching their door could not be the order the ruling names, and refused to + guess at it. They were right, and the reader was wrong. + + Alphabetical-by-resolved-path is an accident of what someone named a + directory. The row order in the anchor is a statement: a human wrote these + rows in this sequence. Both are deterministic — only one carries intent, so + only one can break a tie the ruling says intent breaks. + """ + + def test_roots_come_back_in_the_order_they_were_declared(self, machine): + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"), _root_row("../Demo"))) + assert [path.name for path in rs.declared_roots(machine)] == ["wren", "Demo"] + + def test_the_reverse_declaration_gives_the_reverse_order(self, machine): + """Paired with the above so alphabetical order cannot pass both.""" + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../Demo"), _root_row("../wren"))) + assert [path.name for path in rs.declared_roots(machine)] == ["Demo", "wren"] + + def test_a_skipped_row_does_not_reorder_the_rows_that_survive(self, machine): + """Refusals close the gap in place; they never shuffle the remainder.""" + _write( + machine / rs.DECLARED_ROOTS, + _roots(_root_row("../wren"), _root_row("../nowhere"), _root_row("../Demo")), + ) + assert [path.name for path in rs.declared_roots(machine)] == ["wren", "Demo"] + + def test_a_root_declared_twice_keeps_its_FIRST_position(self, machine): + """Dedup by first occurrence — a later duplicate cannot promote a root.""" + _write( + machine / rs.DECLARED_ROOTS, + _roots(_root_row("../wren"), _root_row("../Demo"), _root_row("../wren/")), + ) + assert [path.name for path in rs.declared_roots(machine)] == ["wren", "Demo"] + + def test_the_external_walk_visits_roots_in_declaration_order(self, machine): + """The order has to survive the caller too, or the fix stops at the reader.""" + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"), _root_row("../Demo"))) + visited = [Path(item["path"]) for item in rs.external_branches(machine)] + # Path.parts, never a string split. A separator is an OS detail, and + # asking "/wren/" of a path spelled with backslashes answers no on + # Windows for every root — which read as "no citizens" rather than as + # "the test cannot see them". + roots = [next((name for name in ("wren", "Demo") if name in path.parts), None) for path in visited] + assert {"wren", "Demo"} <= set(roots), f"both roots must contribute citizens, got {visited}" + assert roots.index("wren") < roots.index("Demo") + + +class TestARetiredCitizenStaysRetired: + """@spawn's archive hazard, measured on the real machine and pinned here. + + Preparing their external migration, @spawn checked the obvious extension of + their resident glob -- ``src/*/*/.trinity/passport.json`` -- against the live + fleet and found FIVE archived passports under Vera-Studio's + ``src/.archive/``. A passport walk would have "found" 11 external citizens + where the fleet has 6, and offered to write into five directories somebody + deliberately retired. + + Measured against the live machine at the time of writing: the passport walk + returns 9 Vera-Studio branches, the registry-led walk returns 4. That gap is + the whole argument. A passport on disk cannot tell you whether its citizen + is live -- retiring is a REGISTRY act, so only the registry knows. This is + why the walk law is a law and not a preference, and it is pinned + synthetically here so the rule survives without depending on anyone's + machine still having that .archive directory. + """ + + def test_an_archived_passport_is_not_a_citizen(self, machine): + _write(machine.parent / "wren/src/.archive/ghosttown/.trinity/passport.json", {"citizenship": {}}) + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"))) + names = [item["name"] for item in rs.external_branches(machine)] + assert "ghosttown" not in [n.lower() for n in names], names + assert names, "the live citizens must still be found -- an empty result would pass vacuously" + + def test_a_registry_that_retired_a_branch_does_not_return_it(self, machine): + """The registry is the authority on liveness, and it is consulted.""" + root = machine.parent / "wren" + _write( + root / "WREN_REGISTRY.json", + _registry(_branch("live", "src/live"), _branch("gone", "src/gone", status="retired")), + ) + for who in ("live", "gone"): + _write(root / f"src/{who}/.trinity/passport.json", {"citizenship": {}}) + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"))) + names = [item["name"].lower() for item in rs.external_branches(machine)] + assert "live" in names and "gone" not in names, names + + +class TestAMalformedAnchorRefusesInsteadOfCrashing: + """Same discipline as the registry reader: the anchor must never take a lane down.""" + + @pytest.mark.parametrize( + "document", + [ + "[]", + '"roots"', + "7", + "null", + "true", + '{"roots": {"a": 1}}', + '{"roots": "x"}', + '{"roots": 7}', + '{"roots": null}', + "not json at all", + ], + ) + def test_a_malformed_roots_file_declares_nothing(self, machine, document): + (machine / rs.DECLARED_ROOTS).write_text(document, encoding="utf-8") + assert rs.declared_roots(machine) == [] + + +class TestMembershipIsPresenceNotDeclaration: + """The GO's ruling, pinned: a passport on disk makes a citizen. No field required.""" + + def test_a_citizen_with_no_residency_field_is_included(self, machine): + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"))) + names = {item["name"] for item in rs.external_branches(machine)} + assert names == {"wren", "quiet"} + + def test_a_listed_branch_without_a_passport_is_not_a_citizen(self, machine): + """`ghost` is active in wren's registry and has no .trinity/ at all.""" + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"))) + assert "ghost" not in {item["name"] for item in rs.external_branches(machine)} + + def test_every_external_record_is_labelled_external(self, machine): + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"), _root_row("../Demo"))) + found = rs.external_branches(machine) + assert found, "nothing was discovered, so the label was never exercised" + assert {item["residency"] for item in found} == {rs.RESIDENCY_EXTERNAL} + + def test_the_record_carries_no_scheduler_flag(self, machine): + """Withdrawn 2026-08-30 at the request of the only branch that asked for it. + + The field reported ONE FILENAME -- whether `.daemon/schedule.json` + exists. @daemon reads `.daemon/*.json`, every file in there. So a + consumer using this as a pre-filter would silently drop every job + living in a differently-named file, and it would read as "this citizen + has no jobs" rather than as a bug. @daemon's words: a field that is + nearly right is worse than no field. + + It bought nothing either -- their discovery opens what it globs + regardless, so a bool cannot save a stat they have to do anyway. + + This pin is the shape, not the absence of one key: a record that grows + a field nobody consumes should have to come past a test. + """ + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"))) + found = rs.external_branches(machine) + assert found, "nothing was discovered, so the record shape was never exercised" + for item in found: + assert set(item) == {"name", "path", "registry", "email", "residency"}, item + + def test_roots_are_independent_of_each_other(self, machine): + """A broken root must cost its own citizens and nobody else's.""" + (machine.parent / "Demo" / "DEMO_REGISTRY.json").write_text("[]", encoding="utf-8") + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"), _root_row("../Demo"))) + assert {item["name"] for item in rs.external_branches(machine)} == {"wren", "quiet"} + + def test_a_refusal_names_the_tier_it_refused_from(self, machine, caplog): + """A log line is the whole product of a refusal, so its words are behaviour. + + Found by mutation: swapping the tier word in the refusal survived every + other test here, because nothing read the line. An external citizen + logged as a REFUSED resident sends whoever greps for it into + ``projects/``, looking for a branch that lives in another repository. + """ + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"))) + with caplog.at_level("ERROR"): + rs.external_branches(machine) + refusals = [record.getMessage() for record in caplog.records if "REFUSED" in record.getMessage()] + assert refusals, "the passportless branch was refused silently" + assert any(f"REFUSED {rs.RESIDENCY_EXTERNAL} 'ghost'" in line for line in refusals), refusals + + def test_two_registries_in_one_root_is_a_refusal_not_a_pick(self, machine): + """Ambiguity is refused by name, never resolved alphabetically. + + Raised by @drone (mail f94e63af) against their own code: their + ``_first_registry_in`` does ``sorted(glob)[0]``, which they called a + fallback wearing a determinism costume — a second registry in a root + would be silently ignored and nobody would ever learn which one lost. + They recommended I not copy it. + + Merging the two would be worse than picking: it invents a union nobody + declared and makes the root's own fleet a thing only this reader knows. + So the ROOT contributes nothing and says so, and the other roots are + unaffected — one ambiguous root costs one root. + + Measured before pinning: all three live roots hold exactly one registry, + so this is the shape of a future defect, not today's. + """ + _write(machine.parent / "wren" / "EXTRA_REGISTRY.json", _registry(_branch("extra", "src/extra"))) + _write(machine.parent / "wren/src/extra/.trinity/passport.json", {"citizenship": {}}) + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"), _root_row("../Demo"))) + assert {item["name"] for item in rs.external_branches(machine)} == {"solo"} + + def test_a_root_with_no_registry_is_named_not_guessed(self, machine): + """No registry means no citizens -- never a passport walk to find some.""" + bare = machine.parent / "Bare" + _write(bare / "src/someone/.trinity/passport.json", {"citizenship": {}}) + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../Bare"))) + assert rs.external_branches(machine) == [] + + +class TestDiscoveryInsideARootIsShallowAndRegistryLed: + """The walk law, carried across the repo boundary unchanged.""" + + def test_a_registry_one_level_down_is_not_found(self, machine): + """Depth zero only. A registry under a subdirectory is somebody's copy.""" + nested = machine.parent / "Nested" + _write(nested / "sub" / "NESTED_REGISTRY.json", _registry(_branch("deep", "src/deep"))) + _write(nested / "sub/src/deep/.trinity/passport.json", {"citizenship": {}}) + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../Nested"))) + assert rs.external_branches(machine) == [] + + def test_a_backup_copy_of_a_passport_is_never_reached(self, machine): + """@baud's shape, one repo over. Registry-led means the copy is unreachable.""" + _write(machine.parent / "wren/.backup/src/wren/.trinity/passport.json", {"citizenship": {}}) + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"))) + paths = [str(item["path"]) for item in rs.external_branches(machine)] + assert not any(".backup" in path for path in paths) + + +class TestTheFleetGrowsWithoutMovingUnderneathAnyone: + """Adding a tier must not change what the existing tiers answer.""" + + def test_with_no_roots_file_the_fleet_is_exactly_what_it_was(self, machine): + before = rs.fleet_branches(machine) + _write(machine / rs.DECLARED_ROOTS, _roots()) + assert rs.fleet_branches(machine) == before + + def test_externals_join_the_fleet(self, machine): + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"))) + names = {item["name"] for item in rs.fleet_branches(machine)} + assert names == {"alpha", "wren", "quiet"} + + def test_every_record_carries_its_tier(self, machine): + """@daemon asked for source labels so external never silently reads as core.""" + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"))) + tiers = {item["name"]: item["residency"] for item in rs.fleet_branches(machine)} + assert tiers == {"alpha": rs.RESIDENCY_CORE, "wren": rs.RESIDENCY_EXTERNAL, "quiet": rs.RESIDENCY_EXTERNAL} + + def test_an_external_root_that_is_this_repo_cannot_double_count(self, machine): + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("."))) + assert [item["name"] for item in rs.fleet_branches(machine)] == ["alpha"] + + +class TestTheGatewayCarriesTheNewSurface: + """@daemon consumes through modules/fleet.py and must not need a second import.""" + + def test_the_new_names_are_on_the_public_gateway(self): + from aipass.memory.apps.modules import fleet + + for name in ("declared_roots", "external_branches", "RESIDENCY_EXTERNAL", "DECLARED_ROOTS"): + assert getattr(fleet, name) is getattr(rs, name), f"{name} is not the handler's own object" + + +class TestTheLiveMachineIsReachable: + """The acceptance case, driven against the REAL sibling repos on this disk. + + Deliberately does not write ``AIPASS_ROOTS.json`` into the repo: that file is + Patrick's to bless and populate, and a test that creates it would both + pre-empt him and make the anchor look self-installing. So the roots file is + written to a throwaway copy of the repo root instead, pointed at the real + external repos, which proves the reader against real registries and real + passports without putting anything in the tree. + """ + + def test_wren_and_vera_studio_are_reachable_when_declared(self, tmp_path): + live_home = rs.find_repo_root() + projects = live_home.parent + wanted = {"wren": projects / "wren", "Vera-Studio": projects / "Vera-Studio"} + missing = [name for name, path in wanted.items() if not path.is_dir()] + if missing: + pytest.skip(f"external repos not installed on this machine: {missing} -- live guard skipped") + + stand_in = tmp_path / "AIPass" + stand_in.mkdir() + (stand_in / "AIPASS_REGISTRY.json").write_text( + (live_home / "AIPASS_REGISTRY.json").read_text(encoding="utf-8"), encoding="utf-8" + ) + _write( + stand_in / rs.DECLARED_ROOTS, + _roots( + {"path": str(wanted["wren"]), "label": "wren", "status": "active"}, + {"path": str(wanted["Vera-Studio"]), "label": "vera-studio", "status": "active"}, + ), + ) + + found = {item["name"]: item for item in rs.external_branches(stand_in)} + assert "wren" in found, f"@wren is the fence citizen and must be reachable; got {sorted(found)}" + assert "vera" in found, f"@vera must be reachable; got {sorted(found)}" + assert all(item["residency"] == rs.RESIDENCY_EXTERNAL for item in found.values()) + assert all(item["email"] for item in found.values()), "external citizens must be addressable" + + def test_no_live_external_citizen_declares_a_residency(self): + """The measurement the GO acted on, kept as a live guard. + + If this ever goes red it means the schema campaign happened after all, + and the presence rule can be revisited on evidence rather than memory. + """ + projects = rs.find_repo_root().parent + declared = [] + for repo in ("wren", "Vera-Studio", "Demo", "feel_good_app"): + root = projects / repo + if not root.is_dir(): + continue + for registry in root.glob("*_REGISTRY.json"): + for item in rs.read_registry_branches(registry, name_from="name"): + if rs.declared_residency(item["path"]) is not None: + declared.append(item["name"]) + assert not declared, ( + f"external citizens now declare a residency: {declared} -- the presence rule can be revisited" + ) + + +class TestACaseInsensitiveFilesystemCannotWidenTheWalk: + """``*_REGISTRY.json`` is a rule about names, and a glob is not that rule. + + @drone hit this on the Windows CI leg: their own tree carries a real + lowercase ``..._registry.json``, and Windows globs case-insensitively, so + the pattern matched a file the rule excludes. @devpulse routed the question + to every walk owner. Both of mine were exposed. + + THE DECOY'S STEM DIFFERS FROM THE REAL REGISTRY'S, and that is a + correctness requirement rather than a stylistic choice. A first version used + ``wren_registry.json`` beside ``WREN_REGISTRY.json`` — two names differing + only by case, which a folding filesystem CANNOT HOLD AT ONCE. On the Windows + CI leg the second write did not add a decoy, it OVERWROTE the real + registry's content under the original filename, and the test then measured + an overwrite while claiming to measure case-globbing. It failed for a reason + unrelated to the defect, which is the luckiest possible outcome; it could + just as easily have passed. + + Distinct stems (``flow_json_registry.json``, exactly the real bait @drone + hit) coexist on every filesystem, so the SAME world is built on a folding + host and a case-sensitive one. The emulation is what widens the glob here; + on Windows the filesystem does it natively and the emulation is a harmless + no-op. Nothing is skipped by platform and nothing needs to probe the host. + + IT IS NOT A COSMETIC WIDENING HERE, which is why it earns pins rather than + a note. Both walks read the match COUNT as meaning: zero registries at a + declared root is an error, and more than one is a named refusal rather than + a ``sorted()[0]`` pick. So a spurious lowercase neighbour does not add a + stray citizen — it silently turns a root that works on Linux into a root + that refuses on Windows, and every citizen behind it vanishes from the + fleet with a message blaming the repo owner for a file they never wrote. + """ + + def test_the_injected_world_really_widens(self, machine, case_insensitive_filesystem): + """The positive control: prove the fixture has teeth before trusting it. + + A blinded instrument reports green for the same reason a cured defect + does. This asserts the raw glob — no filter — genuinely returns the + lowercase neighbour, so the pins below are testing a real widening and + not an emulation that quietly does nothing. + """ + wren = machine.parent / "wren" + _write(wren / "flow_json_registry.json", _registry(_branch("mirage", "src/mirage"))) + + matched = sorted(path.name for path in wren.glob(rs.EXTERNAL_REGISTRY_GLOB)) + + assert matched == ["WREN_REGISTRY.json", "flow_json_registry.json"] + + def test_a_lowercase_neighbour_does_not_make_a_root_ambiguous(self, machine, case_insensitive_filesystem): + """The defect: one stray filename, and every citizen behind it is gone.""" + wren = machine.parent / "wren" + _write(wren / "flow_json_registry.json", _registry(_branch("mirage", "src/mirage"))) + _write(wren / "src/mirage/.trinity/passport.json", {"citizenship": {}}) + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../wren"))) + + names = {item["name"] for item in rs.external_branches(machine)} + + assert "wren" in names, "the declared root refused itself over a file it does not declare" + assert "mirage" not in names, "a lowercase registry was read as though it spelled the rule" + + def test_a_lowercase_registry_is_not_a_root_s_only_registry(self, machine, case_insensitive_filesystem): + """A root carrying ONLY a lowercase name carries none: absence, said out loud. + + The complement of the case above, and the one that would look like a + fix if the filter were written as "drop the extras". There is nothing + to keep here, and the walk has to reach the no-registry error rather + than read the file. + """ + lonely = machine.parent / "Lonely" + _write(lonely / "flow_json_registry.json", _registry(_branch("nobody", "src/nobody"))) + _write(lonely / "src/nobody/.trinity/passport.json", {"citizenship": {}}) + _write(machine / rs.DECLARED_ROOTS, _roots(_root_row("../Lonely"))) + + assert rs.external_branches(machine) == [] + + +class TestTheExactCaseFilterIsAboutNamesNotPlatforms: + """The predicate on its own, on every platform, with no filesystem at all.""" + + def test_it_keeps_the_exact_case_and_drops_every_other_spelling(self): + candidates = [ + Path("/x/AIPASS_REGISTRY.json"), + Path("/x/WREN_REGISTRY.json"), + Path("/x/wren_registry.json"), + Path("/x/Wren_Registry.Json"), + Path("/x/WREN_REGISTRY.JSON"), + ] + + kept = rs._exactly_named(candidates, rs.CORE_REGISTRY_SUFFIX) + + assert [path.name for path in kept] == ["AIPASS_REGISTRY.json", "WREN_REGISTRY.json"] + + def test_the_suffix_has_to_END_the_name_not_merely_appear_in_it(self): + """A backup beside the registry is not a registry. + + ``in`` instead of ``endswith`` reads ``AIPASS_REGISTRY.json.bak`` as the + fleet and hands a stale document to every lane behind this walk. It is + the one substitution in this predicate a test can actually see. + + AN EQUIVALENT MUTANT, recorded because a mutation run that reports it as + killed is lying and the next reader deserves the reason. Swapping + ``path.name.endswith(suffix)`` for ``str(path).endswith(suffix)`` + SURVIVES every test in this class, and no test can kill it: the suffix + contains no separator, so a full path can only end with it when its last + component does. The two are the same function on any input. ``.name`` is + written anyway because it says what the rule IS — a rule about + filenames — and the equivalence is a property of this suffix rather than + a promise the next one will keep. + """ + kept = rs._exactly_named( + [Path("/x/AIPASS_REGISTRY.json.bak"), Path("/x/AIPASS_REGISTRY.json")], rs.CORE_REGISTRY_SUFFIX + ) + + assert [path.name for path in kept] == ["AIPASS_REGISTRY.json"] + + def test_it_narrows_and_never_reorders(self): + """The walk sorts before filtering; the filter must not undo that. + + The sample is deliberately one a case-folding re-sort would reverse + (``B`` before ``aa`` by ASCII, after it by ``lower()``). A first draft + used ``A``/``C`` and a re-sorting mutant survived it — any order-blind + sample makes an order claim that cannot fail. + """ + candidates = [Path("/x/B_REGISTRY.json"), Path("/x/skip_registry.json"), Path("/x/aa_REGISTRY.json")] + + kept = rs._exactly_named(candidates, rs.CORE_REGISTRY_SUFFIX) + + assert kept == [Path("/x/B_REGISTRY.json"), Path("/x/aa_REGISTRY.json")] + + +class TestWhatTheHostCanActuallyHold: + """A probe, not an assumption — the shape @devpulse asked for. + + The tests above are built on the claim that a decoy whose STEM differs + coexists with the real registry everywhere, while one differing only by CASE + does not. That claim decides how every case-glob pin in this branch is + written, so it is measured rather than asserted in a comment, on whatever + filesystem is running the suite. + + Both outcomes are legal and both are pinned. What would not be legal is a + ``skipif`` here: the host can be asked, and a platform guess is what put an + overwrite behind a test that claimed to be about globbing. + """ + + def test_two_names_differing_only_by_case_may_be_one_file(self, tmp_path): + (tmp_path / "A_REGISTRY.json").write_text("first", encoding="utf-8") + (tmp_path / "a_registry.json").write_text("second", encoding="utf-8") + + entries = sorted(path.name for path in tmp_path.iterdir()) + + if len(entries) == 1: + # Folding host: the directory entry keeps the ORIGINAL spelling and + # the CONTENT is replaced. That is why a case-only decoy silently + # became an overwrite of the real registry on the Windows CI leg. + assert entries == ["A_REGISTRY.json"], entries + assert (tmp_path / "A_REGISTRY.json").read_text(encoding="utf-8") == "second" + else: + assert entries == ["A_REGISTRY.json", "a_registry.json"] + + def test_a_differing_stem_is_always_a_second_file(self, tmp_path): + """The property every case-glob pin in this branch relies on.""" + (tmp_path / "A_REGISTRY.json").write_text("first", encoding="utf-8") + (tmp_path / "flow_json_registry.json").write_text("second", encoding="utf-8") + + assert len(list(tmp_path.iterdir())) == 2 diff --git a/src/aipass/memory/tests/test_detector.py b/src/aipass/memory/tests/test_detector.py index 291005dce..95249bd03 100644 --- a/src/aipass/memory/tests/test_detector.py +++ b/src/aipass/memory/tests/test_detector.py @@ -862,3 +862,93 @@ def test_read_registry_discovers_external_branches_via_known(self, tmp_path: Pat names = [b["name"] for b in branches] assert "memory" in names assert "mybranch" in names + + +class TestTheCallerCwdWalkReadsNamesNotSpellings: + """The third of this tree's four ``*_REGISTRY.json`` walks, and the costliest. + + @drone found the case-folding widening on the Windows CI leg and @devpulse + routed it to every walk owner; ``registry_scope`` was cured first and this + site was named next, by @seedgo's measured fleet discriminator. + + WHY THIS ONE HAD THE MOST TO LOSE. It walks up from the CALLER'S directory — + an arbitrary repo this branch does not own — so the bait is not + hypothetical: flow's plan counters and ``.spawn/.template_registry.json`` + sit in every branch. And a folded match here is not merely read once: + + * it is ``persist_registry()``'d into known_registries.json PERMANENTLY, so + one run on a folding filesystem leaves the wrong file in the fleet's + state forever, and + * the ``break`` after the first hit means a spurious NEARER match stops the + walk before the real registry above it is ever seen. + + Refusing, admitting and forgetting — the other three walks each do one of + those. This one does all three. + + See ``case_insensitive_filesystem`` in conftest for why the condition is + injected rather than skipped off-platform. + """ + + @pytest.fixture + def caller_tree(self, tmp_path, monkeypatch): + """A caller standing in someone else's repo, with the bait beside the real file.""" + from aipass.memory.apps.handlers.monitor import detector + + monkeypatch.setattr(detector, "_KNOWN_REGISTRIES_PATH", tmp_path / "known_registries.json") + monkeypatch.setattr(detector, "_REPO_ROOT", tmp_path / "aipass_home") + (tmp_path / "aipass_home").mkdir() + + foreign = tmp_path / "foreign_project" + foreign.mkdir() + (foreign / "FOREIGN_REGISTRY.json").write_text('{"branches":[]}', encoding="utf-8") + (foreign / "flow_json_registry.json").write_text('{"branches":[]}', encoding="utf-8") + monkeypatch.setenv("AIPASS_CALLER_CWD", str(foreign)) + return foreign + + def test_the_injected_world_really_widens(self, caller_tree, case_insensitive_filesystem): + """Positive control — a blinded fixture reports green exactly like a cure.""" + matched = sorted(path.name for path in caller_tree.glob("*_REGISTRY.json")) + + assert matched == ["FOREIGN_REGISTRY.json", "flow_json_registry.json"] + + def test_a_lowercase_counter_file_is_never_read_as_a_registry(self, caller_tree, case_insensitive_filesystem): + from aipass.memory.apps.handlers.monitor import detector + + found = [path.name for path in detector._find_caller_registries()] + + assert found == ["FOREIGN_REGISTRY.json"] + + def test_a_lowercase_counter_file_is_never_persisted(self, caller_tree, case_insensitive_filesystem): + """The part that outlives the run. State written once is believed forever.""" + from aipass.memory.apps.handlers.monitor import detector + + detector._find_caller_registries() + + persisted = json.loads(detector._KNOWN_REGISTRIES_PATH.read_text(encoding="utf-8"))["registries"] + assert not any("flow_json_registry.json" in entry for entry in persisted) + + def test_bait_nearer_than_the_real_registry_does_not_end_the_walk( + self, tmp_path, monkeypatch, case_insensitive_filesystem + ): + """The ``break`` is the second defect, and it is invisible in the first test. + + With the bait in a SUBdirectory the walk starts from, an unfiltered glob + stops there and the genuine registry one level up is never found — a + silent narrowing, not a noisy wrong answer. + """ + from aipass.memory.apps.handlers.monitor import detector + + monkeypatch.setattr(detector, "_KNOWN_REGISTRIES_PATH", tmp_path / "known_registries.json") + monkeypatch.setattr(detector, "_REPO_ROOT", tmp_path / "aipass_home") + (tmp_path / "aipass_home").mkdir() + + foreign = tmp_path / "foreign_project" + inner = foreign / "subdir" + inner.mkdir(parents=True) + (foreign / "FOREIGN_REGISTRY.json").write_text('{"branches":[]}', encoding="utf-8") + (inner / "flow_json_registry.json").write_text('{"branches":[]}', encoding="utf-8") + monkeypatch.setenv("AIPASS_CALLER_CWD", str(inner)) + + found = [path.name for path in detector._find_caller_registries()] + + assert found == ["FOREIGN_REGISTRY.json"] diff --git a/src/aipass/memory/tests/test_fleet_gateway.py b/src/aipass/memory/tests/test_fleet_gateway.py new file mode 100644 index 000000000..edf85e1bb --- /dev/null +++ b/src/aipass/memory/tests/test_fleet_gateway.py @@ -0,0 +1,141 @@ +# =================== AIPass ==================== +# Name: test_fleet_gateway.py +# Description: Pins the public cross-branch gateway for the fleet definition +# Version: 1.0.0 +# Created: 2026-08-30 +# Modified: 2026-08-30 +# ============================================= + +"""The fleet definition has one owner and now one door. + +@daemon reported (dispatch 2a70bbcd) that the cross-branch import I sanctioned +fails two of @seedgo's checks at once. The rule is not arbitrary and it is not +"do not consume @memory" — ``handlers_check.py:310`` says another branch's +``modules`` package is its PUBLIC GATEWAY and sends cross-branch callers there +explicitly. ``apps/handlers/`` is private implementation. + +So the seam moves, and nothing else does. ``registry_scope`` stays the single +definition; this module is the door to it, owned here rather than shimmed in +each consumer's tree — a gateway living in @daemon would be a second public +surface for my module in a branch I do not control, and the next consumer would +import theirs or write a third. + +What is deliberately NOT re-exported is as much of the contract as what is: +``resident_registry_paths`` and ``read_registry_branches`` are the mechanics of +HOW residents are found, and they stop being the whole story the moment the +external tier lands. +""" + +import importlib +from pathlib import Path + +import pytest + +from aipass.memory.apps.handlers.monitor import registry_scope +from aipass.memory.apps.modules import fleet + + +CONTRACT = ( + "fleet_branches", + "find_repo_root", + "declared_residency", + "accepted_resident_paths", + "declared_roots", + "external_branches", + "RESIDENCY_CORE", + "RESIDENCY_RESIDENT", + "RESIDENCY_EXTERNAL", + "DECLARED_ROOTS", +) + +INTERNAL = ("resident_registry_paths", "read_registry_branches", "_refuse", "_accepted_residents") + + +class TestTheGatewayIsADoorNotACopy: + """Re-export, never reimplement. A gateway that computes anything is a second definition.""" + + @pytest.mark.parametrize("name", CONTRACT) + def test_every_contract_name_is_the_same_object_as_the_handler_s(self, name): + """Identity, not equality. A wrapper that merely AGREES today is the defect this ends.""" + assert getattr(fleet, name) is getattr(registry_scope, name) + + def test_dunder_all_is_exactly_the_contract(self): + assert tuple(fleet.__all__) == CONTRACT + + @pytest.mark.parametrize("name", INTERNAL) + def test_the_internals_stay_behind_the_door(self, name): + """Named one by one: a later re-export would silently widen what I must not break.""" + assert not hasattr(fleet, name), f"{name} is internal and must not be part of the public gateway" + + +class TestTheCommandSurfaceIsIntrospectionOnly: + """A correction to my own first design, kept visible rather than quietly rewritten. + + I built this gateway with no ``handle_command``, reasoning that a library + surface should stay invisible to ``apps/memory.py``'s duck-typed module + discovery, and wrote a test asserting exactly that. The branch's own + convention disagreed: ``health.py`` — the gateway built for the same + consumer, for the same reason — is discoverable and answers introspection, + and three @seedgo standards (cli, introspection, json_structure) fail + without it. The convention won and the test inverted. + + What stays true either way is the part that matters: the CLI describes the + contract and never executes fleet work. A gateway whose command surface + starts answering fleet questions is the second definition all over again. + """ + + def test_it_answers_only_its_own_command(self): + """A module that claims a command it does not own swallows another module's work.""" + assert fleet.handle_command("rollover", []) is False + assert fleet.handle_command("search", ["x"]) is False + + @pytest.mark.parametrize("args", [[], ["--help"], ["-h"], ["help"]]) + def test_the_bare_command_and_every_help_spelling_introspect(self, capsys, args): + assert fleet.handle_command("fleet", args) is True + assert "fleet Module" in capsys.readouterr().out + + def test_an_unknown_subcommand_is_named_not_swallowed(self, capsys): + """Claimed and reported, never a silent no-op that looks like success. + + Read from BOTH streams on purpose: the warning routes to stderr under + @seedgo's output-routing standard, and asserting stdout alone made this + red on the first run for a reason that had nothing to do with the + behaviour being pinned. + """ + assert fleet.handle_command("fleet", ["nonsense"]) is True + captured = capsys.readouterr() + assert "nonsense" in captured.out + captured.err + + def test_the_command_surface_executes_no_fleet_work(self, monkeypatch): + """The pin that keeps this a door: the CLI must never answer the question itself.""" + called = [] + monkeypatch.setattr(registry_scope, "fleet_branches", lambda *a, **k: called.append(a) or []) + monkeypatch.setattr(registry_scope, "accepted_resident_paths", lambda *a, **k: called.append(a) or set()) + fleet.handle_command("fleet", []) + assert not called, "the gateway's CLI computed fleet state instead of describing the contract" + + def test_it_is_discovered_by_the_real_loops_own_rule(self): + """Asserted against the discovery rule rather than trusted. + + ``apps/memory.py`` duck-types every file in ``modules/`` on + ``handle_command``. This confirms the rule is still what the module was + written against, so the shape above is deliberate and not incidental. + """ + modules_dir = Path(fleet.__file__).parent + commanded = [] + for path in sorted(modules_dir.glob("*.py")): + if path.name == "__init__.py": + continue + module = importlib.import_module(f"aipass.memory.apps.modules.{path.stem}") + if hasattr(module, "handle_command"): + commanded.append(path.stem) + assert "fleet" in commanded + assert "health" in commanded, "the precedent gateway no longer follows the convention this one copied" + + +class TestTheDoorAnswersTheSameAsTheRoom: + """One behavioural pass, so the re-export is proven to carry real answers.""" + + def test_fleet_branches_through_the_gateway_matches_the_handler(self): + repo_root = registry_scope.find_repo_root() + assert fleet.fleet_branches(repo_root) == registry_scope.fleet_branches(repo_root) diff --git a/src/aipass/memory/tests/test_import_isolation.py b/src/aipass/memory/tests/test_import_isolation.py index d71af16ab..df705d818 100644 --- a/src/aipass/memory/tests/test_import_isolation.py +++ b/src/aipass/memory/tests/test_import_isolation.py @@ -233,6 +233,68 @@ def test_every_stand_in_installed_at_the_package_name_carries_a_path(self): offenders.append(f"{path.name}: {variable} is installed at {_PACKAGE} with no __path__") assert not offenders, "package stand-ins that are not packages:\n " + "\n ".join(offenders) + # Measured 2026-08-30. Every stand-in installed at a name this suite ALSO + # installs children under — i.e. impersonating a package — while carrying no + # __path__. Sixteen of them. They are listed rather than fixed because the + # mechanical fix (assign __path__) broke 41 tests in one pass: several of + # these shadow their whole subtree ON PURPOSE, and telling them apart is a + # per-file reading, not a sed. Named so a SEVENTEENTH cannot arrive quietly. + KNOWN_BARE_PACKAGE_STAND_INS = { + "conftest.py::aipass.prax.apps.modules", + "test_orchestrator_exec.py::aipass.memory.apps.handlers.monitor", + "test_orchestrator_exec.py::aipass.memory.apps.handlers.tracking", + "test_rollover.py::aipass.memory.apps.handlers", + "test_rollover.py::aipass.memory.apps.handlers.cli", + "test_rollover.py::aipass.memory.apps.handlers.intake", + "test_rollover.py::aipass.memory.apps.handlers.monitor", + "test_rollover.py::aipass.memory.apps.handlers.rollover", + "test_rollover_pipeline.py::aipass.memory.apps.handlers.monitor", + "test_rollover_pipeline.py::aipass.memory.apps.handlers.rollover", + "test_rollover_pipeline.py::aipass.memory.apps.handlers.tracking", + "test_symbolic.py::aipass.memory.apps.handlers.symbolic", + "test_symbolic_cli.py::aipass.memory.apps.handlers.symbolic", + "test_symbolic_extras.py::aipass.memory.apps.handlers.vector", + "test_symbolic_module.py::aipass.memory.apps.handlers.symbolic", + } + + def _bare_package_stand_ins(self): + installs = re.compile(r"setitem\(sys\.modules,\s*\"([\w.]+)\"\s*,\s*([\w.]+)\s*\)") + found_all = set() + for path in sorted(_TESTS.glob("test_*.py")) + [_TESTS / "conftest.py"]: + source = path.read_text(encoding="utf-8") + found = installs.findall(source) + names = {name for name, _ in found} + for name, variable in found: + impersonates_package = any(o != name and o.startswith(name + ".") for o in names) + if impersonates_package and f"{variable}.__path__" not in source: + found_all.add(f"{path.name}::{name}") + return found_all + + def test_no_NEW_package_stand_in_arrives_without_a_path(self): + """The rule above was written for one package. The defect had seventeen. + + `aipass.prax` was a bare MagicMock in conftest, six lines above the json + stand-in that carries `__path__` and a paragraph explaining why it must. + One package learned the lesson; the other sat beside it, because the pin + named a CONSTANT instead of a SHAPE. Widening the shape found fourteen + more in six other files. + + `aipass.prax` is fixed. The rest are inventoried above, not swept: they + are a real backlog and this test says so out loud rather than passing as + if the suite were clean. What it defends is the edge — a new one cannot + be added without either fixing it or consciously adding a line here. + + A name counts as a PACKAGE when the same file also installs something + beneath it, so a package added tomorrow is covered without editing this. + """ + new = self._bare_package_stand_ins() - self.KNOWN_BARE_PACKAGE_STAND_INS + assert not new, "new package stand-in with no __path__:\n " + "\n ".join(sorted(new)) + + def test_the_inventory_does_not_outlive_what_it_inventories(self): + """A stale allow-list is how a fixed thing stays 'known broken' forever.""" + stale = self.KNOWN_BARE_PACKAGE_STAND_INS - self._bare_package_stand_ins() + assert not stale, "fixed — remove from KNOWN_BARE_PACKAGE_STAND_INS:\n " + "\n ".join(sorted(stale)) + @pytest.mark.parametrize("name", ["test_json_handler", "test_tab_renderer", "test_config_loader"]) def test_the_three_converted_fixtures_still_use_delitem(self, name): """Named one by one: a fixture reverting to a bare pop is a silent relapse.""" diff --git a/src/aipass/memory/tests/test_marker7_memory_lane.py b/src/aipass/memory/tests/test_marker7_memory_lane.py index ceb8c4958..50155bf4c 100644 --- a/src/aipass/memory/tests/test_marker7_memory_lane.py +++ b/src/aipass/memory/tests/test_marker7_memory_lane.py @@ -33,6 +33,9 @@ import sys import types from pathlib import Path + +import pytest +from _pytest.outcomes import Skipped # what pytest.skip() raises — caught here, never raised from unittest.mock import patch # Imported at MODULE level on purpose. conftest's autouse fixture replaces @@ -116,6 +119,146 @@ def test_the_push_and_the_registry_lane_agree_on_the_fleet(self, live_fleet): registry_names = {Path(item["path"]).name.lower() for item in detector._read_registry()} assert push_names <= registry_names, f"invisible to rollover/lint/health: {push_names - registry_names}" + def test_the_push_never_reaches_into_another_repository(self, live_fleet): + """The template push WRITES. So its scope stops at this repo's edge. + + `fleet_branches` grew an external tier in 3.0.0 and this lane consumes + it, which briefly put six citizens in four sibling repositories into the + scope of a lane whose whole job is writing files into branches. Nothing + in the external tier build writes to another repo, and the push must not + become the exception. + + The reader is not wrong and the consumer is not wrong about which + function to call — the scope is the thing that needed narrowing, at the + one lane that acts on it rather than reads it. + """ + scoped = trinity_push.resolve_scope()["branches"] + assert scoped, "empty scope proves nothing about what it excludes" + outsiders = [item for item in scoped if item.get("residency") == registry_scope.RESIDENCY_EXTERNAL] + assert not outsiders, f"the push would write into another repository: {[i['name'] for i in outsiders]}" + assert all(str(item["path"]).startswith(str(registry_scope.REPO_ROOT)) for item in scoped) + + def test_every_fleet_record_carries_exactly_the_five_agreed_keys(self, live_all_tiers): + """The whole record shape, across ALL THREE TIERS, not just externals. + + Guarded on `live_all_tiers`, not `live_fleet`: this is the test CI + failed with "got {'core'}" — the guard confirmed a registry existed and + said nothing about the two tiers this assertion is actually about. A + test that needs three tiers must be guarded on three tiers. + + The `scheduler` field was withdrawn from the record on 2026-08-30 at the + request of the only branch that ever wanted it, and the replacement pin + was written against `external_branches` alone. So core and resident rows + were unpinned: a mutant re-adding `item["scheduler"]` to the core loop + passed the entire suite. A record nobody consumes should have to come + past a test on the way in — which only works if the test covers the + tiers a new field would actually be added to. + """ + rows = registry_scope.fleet_branches(live_all_tiers) + assert rows, "an empty fleet proves nothing about record shape" + tiers = {row["residency"] for row in rows} + assert tiers == { + registry_scope.RESIDENCY_CORE, + registry_scope.RESIDENCY_RESIDENT, + registry_scope.RESIDENCY_EXTERNAL, + }, f"all three tiers must be exercised, got {tiers}" + for row in rows: + assert set(row) == {"name", "path", "registry", "email", "residency"}, row + + def test_the_normalize_lane_never_reaches_into_another_repository(self, live_fleet, monkeypatch): + """The third writing lane, found by @trigger's escalation not by design. + + GUARDED 2026-08-31, found by running this suite against a bare-checkout + stand-in: it reads `fleet_branches()` off the live machine and its own + `assert seen` correctly refuses to pass over an empty fleet — so on CI + it was a RED where the honest answer is "this machine has no fleet to + measure". The assertion was right; the guard was missing. + + `_normalize_rolled` resolved `fleet_branches()` — the whole fleet, + externals included — and then matched rolled branches BY NAME. It + writes: `normalize_branch` re-renders the machine frame in place. So the + day any external project names a branch `api` or `flow`, rolling our own + @api would silently rewrite a file in a sibling repository. + + There is no collision today; that is luck, not a guard. Six external + names against twenty-two of ours, and nothing stops the twenty-third. + """ + from aipass.memory.apps.handlers.rollover import normalizer + from aipass.memory.apps.modules import rollover as rollover_module + + seen = [] + monkeypatch.setattr( + normalizer, + "normalize_branch", + lambda name, path, config: seen.append((name, Path(path))) or {"success": True}, + ) + # Every citizen in the fleet claims to have rolled, external ones included. + every_name = [item["name"] for item in registry_scope.fleet_branches()] + rollover_module._normalize_rolled(every_name) + + assert seen, "nothing was normalized, so the scope was never exercised" + home = registry_scope.REPO_ROOT.resolve() + outside = [p for _, p in seen if home not in p.resolve().parents and p.resolve() != home] + assert not outside, f"normalize would write outside this repo: {outside}" + + def test_a_branch_whose_frame_could_not_be_written_is_named(self, live_fleet, monkeypatch, caplog): + """The normalizer reports failure by return value; the caller dropped it. + + GUARDED 2026-08-31, same stand-in run, same species one step subtler: + `_normalize_rolled(["memory"])` resolves @memory through the live fleet + to decide it is in scope. With no registry the branch is not in scope, + so the OUT-OF-SCOPE warning fires instead of the write-failure one and + the test fails naming the wrong message. It reads as a defect in the + reporting it pins, and it is a statement about the machine. + + `if normalize_branch(...)["success"]:` — a False just failed to + increment a counter. The `except` below it catches exceptions the + normalizer explicitly promises never to raise, so it was decorative. + Meanwhile the out-of-scope case IS named. One failure mode announced, + the other invisible, in the same function. + """ + from aipass.memory.apps.handlers.rollover import normalizer + from aipass.memory.apps.modules import rollover as rollover_module + + monkeypatch.setattr( + normalizer, + "normalize_branch", + lambda name, path, config: {"success": False, "error": f"{name}: local.json write failed"}, + ) + # Captured at the logger, not via caplog: prax's logger does not + # necessarily propagate to the root under every suite ordering, and a + # pin that only holds when it runs first is not a pin. + said = [] + monkeypatch.setattr(rollover_module.logger, "warning", lambda msg, *a, **k: said.append(str(msg))) + + rollover_module._normalize_rolled(["memory"]) + + joined = " ".join(said) + assert "memory" in joined, joined + assert "write failed" in joined, joined + + def test_the_rollover_lane_never_reaches_into_another_repository(self, live_fleet): + """Rollover WRITES too — it trims memory files. Same edge as the push. + + The detector resolves through `accepted_resident_paths`, not + `fleet_branches`, so it never inherited the external tier. That is the + correct scope and this pins it, because repointing the detector at the + richer function is an obvious-looking one-line "simplification" and it + would put four sibling repositories inside the write scope of a lane + that edits other citizens' memories. + + Measured while writing it: three external citizens (@verify, @vera, + @research) carry 49 over-cap entries between them. Rollover reaching + them would be a lane writing into repos nobody declared it for. + """ + from aipass.memory.apps.handlers.monitor import detector + + rolled = {Path(item["path"]).resolve() for item in detector._read_registry()} + assert rolled, "an empty scope proves nothing about what it excludes" + home = registry_scope.REPO_ROOT.resolve() + outside = [p for p in rolled if home not in p.parents and p != home] + assert not outside, f"rollover would write outside this repo: {outside}" + def test_the_push_and_the_scope_module_resolve_the_same_fleet(self, live_fleet): """Repointed 2026-08-28: the shared constant is gone, the agreement is not. @@ -123,10 +266,19 @@ def test_the_push_and_the_scope_module_resolve_the_same_fleet(self, live_fleet): claim worth holding is that the two LANES answer the same question the same way, which survives the constant and would have caught the split this module was built to end. + + Narrowed 2026-08-30: the push answers the same question, over the tiers + that live in this repository. The external tier is subtracted HERE, in + the expectation, so the difference between the two lanes stays one named + line of test rather than a drift nobody wrote down. """ - assert {item["name"] for item in trinity_push.resolve_scope()["branches"]} == { - item["name"] for item in registry_scope.fleet_branches(live_fleet) + in_repo = { + item["name"] + for item in registry_scope.fleet_branches(live_fleet) + if item.get("residency") != registry_scope.RESIDENCY_EXTERNAL } + assert in_repo, "an empty expectation would pass against an empty scope" + assert {item["name"] for item in trinity_push.resolve_scope()["branches"]} == in_repo def test_a_missing_resident_registry_is_skipped_not_raised(self, tmp_path): """A checkout with no projects/ must not take out every fleet lane.""" @@ -365,17 +517,23 @@ def test_the_line_counter_still_writes_nothing(self, tmp_path): class TestGrandfatherNarrowedToTodos: - """Post-push the clause hides drift — except where drift is legitimate. - - A non-canonical todo may sit in a branch forever: the push is forbidden to - archive open work (1.1.0) and only its own agent can reshape it. Refusing - every write to such a file would brick that branch's ROLLOVER, which is - the slow-motion data loss item 7 exists to prevent. So `todos` keeps the - exemption and the three archivable containers lose it. - - Mutation notes: removing the clause everywhere (bricks a drifted-todo - branch), keeping it everywhere (hides new over-cap sessions), or keying - the exemption on anything other than the push's own constant. + """The clause was narrowed to todos on 2026-08-27 and widened back on 08-30. + + The narrowing's own argument for the todos exemption — refusing every + write to a file carrying a debt no machine may prune BRICKS that branch's + rollover, the slow-motion data loss item 7 exists to prevent — turned out + to hold for every container, because rollover's write is always a shrink + and it may not touch the entry it is refused for. Three hours of identical + errors were the receipt. + + So the rule is one rule now: a write is refused for what it AUTHORS. + todos are no longer a special case at the cap gate; they remain the one + container the PUSH may not prune, which is a different question and keeps + its own constant. + + Mutation notes: refusing carried entries (deadlocks rollover), skipping + carried entries silently (the 08-27 objection, still valid), or matching + list identity on index instead of text (a prepend re-authors the file). """ @staticmethod @@ -390,14 +548,15 @@ def _limits(max_chars: int = 20) -> dict: }, } - def test_an_unchanged_over_cap_session_is_now_a_violation(self): + def test_an_unchanged_over_cap_session_is_carried_not_a_violation(self): fat = {"number": 1, "date": "2026-08-27", "summary": "x" * 99, "status": "done"} before = {"sessions": [fat]} after = {"sessions": [dict(fat)]} - hits = entry_limits.changed_entries(before, after, self._limits()) + assert entry_limits.changed_entries(before, after, self._limits()) == [] - assert [hit["entry_type"] for hit in hits] == ["sessions"] + carried = entry_limits.classify_entries(before, after, self._limits())["carried"] + assert [hit["entry_type"] for hit in carried] == ["sessions"] def test_an_unchanged_over_cap_todo_is_still_exempt(self): """The one container the push may not cure keeps its exemption.""" @@ -705,3 +864,214 @@ def test_template_status_reports_the_live_receipts(self, live_fleet): assert status["gold"] == template_bump.gold_versions() assert status["branches"], "no branch receipts read" assert all("branch" in row and "carries" in row for row in status["branches"]) + + +# =========================================================================== +# THE GUARD THAT TRUSTED EXISTENCE (2026-08-31) +# =========================================================================== +# +# CI on PR#750, bare ubuntu checkout, `pytest -n auto --dist loadscope` from the +# repo root: `test_every_fleet_record_carries_exactly_the_five_agreed_keys` +# failed with "all three tiers must be exercised, got {'core'}". +# +# The guard was not absent — it PASSED. `live_fleet` asks one question, "is +# there an AIPASS_REGISTRY.json here", and on that worker there was one. It held +# core rows only: no `projects/` in a checkout, so no residents, and +# AIPASS_ROOTS.json is gitignored, so no externals. A three-tier assertion ran +# against a one-tier world and reported the world's shape as a code defect. +# +# @devpulse's evidence for WHY a registry was there at all: on the same worker, +# one test observed registry-absent and a later test observed a core-only +# registry. Nothing ships one. Something in the whole-repo run mints it. +# +# EXISTENCE IS NOT SUFFICIENCY, and it was never only about the third tier — +# `test_the_push_and_the_registry_lane_agree_on_the_fleet` asserts +# `push_names <= registry_names`, which TWO EMPTY SETS satisfy. Its own +# docstring says it is guarded against exactly that, and it was not: an empty +# minted registry passes a file-exists check and the test then agrees about +# nothing. So the guard now reads the rows, not the filename. +# +# The narrowing follows `live_residents`' precedent rather than tightening +# `live_fleet` for everyone: two of its consumers legitimately need only core, +# and a guard that demanded three tiers from all of them would convert honest +# coverage into skips. One question per fixture. +# +# Ground truth is read with pathlib and json, NEVER through registry_scope. +# Asking the code under test whether its own inputs are sufficient would turn +# every regression in the resolver into a SKIP — the guard deleting the failure +# it exists to expose. Same argument as live_residents' literal paths. + + +class TestTheLiveGuardRefusesAHalfPresentWorld: + """The fixtures themselves, against stand-in worlds built on disk.""" + + @staticmethod + def _world( + tmp_path, + *, + rows, + residents=False, + roots=False, + external_citizen=False, + registered_but_absent=False, + dict_shaped=False, + ): + """Build a stand-in repo root with a chosen subset of the three tiers.""" + root = tmp_path / "standin_repo" + (root / "src" / "aipass").mkdir(parents=True) + + if rows is not None: + (root / "AIPASS_REGISTRY.json").write_text( + json.dumps({"metadata": {"version": "1.0.0"}, "branches": rows}, indent=2), + encoding="utf-8", + ) + if residents: + for project, stem in ( + ("baud", "BAUD"), + ("earmark", "EARMARK"), + ("finch", "FINCH"), + ("aipass-site", "AIPASS-SITE"), + ): + directory = root / "projects" / project + directory.mkdir(parents=True) + (directory / f"{stem}_REGISTRY.json").write_text(json.dumps({"branches": []}), encoding="utf-8") + if roots: + sibling = tmp_path / "Sibling-Repo" + sibling.mkdir() + if external_citizen or registered_but_absent: + rows_json = ( + {"wren": {"name": "wren", "path": "wren"}} if dict_shaped else [{"name": "wren", "path": "wren"}] + ) + (sibling / "SIBLING_REGISTRY.json").write_text(json.dumps({"branches": rows_json}), encoding="utf-8") + if external_citizen: + trinity = sibling / "wren" / ".trinity" + trinity.mkdir(parents=True) + (trinity / "passport.json").write_text(json.dumps({"branch_info": {}}), encoding="utf-8") + (root / "AIPASS_ROOTS.json").write_text( + json.dumps({"roots": [{"path": "../Sibling-Repo"}]}, indent=2), encoding="utf-8" + ) + return root + + @staticmethod + def _resolved(request, name): + """Resolve a guard fixture, turning an over-skip into a RED. + + `pytest.skip()` inside a fixture marks the requesting test SKIPPED, and + a skip reads as green. So every positive guard test — "a real world + must pass the guard" — was silenceable by the exact failure it exists + to catch: a mutant that skipped unconditionally turned them into skips, + not failures, and survived. + + Found by mutation, not by reading. A test that cannot fail is not a + test, and one that reports its own defeat as a pass is worse. + """ + try: + return request.getfixturevalue(name) + except Skipped as skipped: # noqa: F841 — re-raised as a failure, deliberately + pytest.fail(f"{name} skipped a world it should have accepted: {skipped}") + + @staticmethod + def _point_both_lanes_at(monkeypatch, root): + """Both roots move together — a guard reading one lane is half a guard.""" + monkeypatch.setattr(registry_scope, "REPO_ROOT", root) + monkeypatch.setattr(trinity_push, "_REPO_ROOT", root) + + _CORE_ROWS = [{"name": "memory", "path": "src/aipass/memory"}] + + def test_a_registry_that_exists_but_holds_no_rows_is_not_a_fleet(self, request, monkeypatch, tmp_path): + """The minted-empty case. `push_names <= registry_names` is true of nothing.""" + self._point_both_lanes_at(monkeypatch, self._world(tmp_path, rows=[])) + + with pytest.raises(Skipped) as skipped: + request.getfixturevalue("live_fleet") + assert "no branch rows" in str(skipped.value) + + def test_an_unreadable_registry_is_absence_not_presence(self, request, monkeypatch, tmp_path): + """Corrupt is not a fleet either — and it must not raise out of a guard.""" + root = self._world(tmp_path, rows=self._CORE_ROWS) + (root / "AIPASS_REGISTRY.json").write_text("{not json", encoding="utf-8") + self._point_both_lanes_at(monkeypatch, root) + + with pytest.raises(Skipped): + request.getfixturevalue("live_fleet") + + def test_a_real_core_registry_still_passes_the_guard(self, request, monkeypatch, tmp_path): + """The hardening must not convert honest core coverage into skips.""" + root = self._world(tmp_path, rows=self._CORE_ROWS) + self._point_both_lanes_at(monkeypatch, root) + + assert self._resolved(request, "live_fleet") == root + + def test_the_ci_shape_is_refused_by_the_three_tier_guard(self, request, monkeypatch, tmp_path): + """THE CI FAILURE, as a test: core rows present, residents and externals absent.""" + self._point_both_lanes_at(monkeypatch, self._world(tmp_path, rows=self._CORE_ROWS)) + + with pytest.raises(Skipped) as skipped: + request.getfixturevalue("live_all_tiers") + assert "resident" in str(skipped.value).lower() + + def test_residents_without_a_roots_file_is_still_half_present(self, request, monkeypatch, tmp_path): + """Two tiers is not three. The roots anchor is machine-local and gitignored.""" + root = self._world(tmp_path, rows=self._CORE_ROWS, residents=True) + self._point_both_lanes_at(monkeypatch, root) + + with pytest.raises(Skipped) as skipped: + request.getfixturevalue("live_all_tiers") + assert registry_scope.DECLARED_ROOTS in str(skipped.value) + + def test_a_roots_file_declaring_nothing_reachable_is_not_a_third_tier(self, request, monkeypatch, tmp_path): + """A declaration is not a citizen. The anchor can be present and empty.""" + root = self._world(tmp_path, rows=self._CORE_ROWS, residents=True, roots=True) + self._point_both_lanes_at(monkeypatch, root) + + with pytest.raises(Skipped) as skipped: + request.getfixturevalue("live_all_tiers") + assert "external" in str(skipped.value).lower() + + def test_a_genuine_three_tier_world_passes(self, request, monkeypatch, tmp_path): + """All three tiers really on disk — the guard must let the assertion run. + + Without this the whole class could be satisfied by a fixture that skips + unconditionally, which would silence the CI red by deleting the test. + """ + root = self._world(tmp_path, rows=self._CORE_ROWS, residents=True, roots=True, external_citizen=True) + self._point_both_lanes_at(monkeypatch, root) + + assert self._resolved(request, "live_all_tiers") == root + + def test_a_registered_external_branch_with_no_passport_is_not_a_citizen(self, request, monkeypatch, tmp_path): + """A ROW is not a citizen either — found by a mutant, not by design. + + Deleting the passport check from the guard left every test green: my + only "no external" world had no registry at all, so the walk stopped + one step earlier and the passport line never ran. Membership is + PRESENCE — a sibling registry naming `wren` while `wren/` is gone is a + declaration about a citizen who is not there, which is the ordinary + state of a machine that has moved on. + """ + root = self._world(tmp_path, rows=self._CORE_ROWS, residents=True, roots=True, registered_but_absent=True) + self._point_both_lanes_at(monkeypatch, root) + + with pytest.raises(Skipped) as skipped: + request.getfixturevalue("live_all_tiers") + assert "external" in str(skipped.value).lower() + + def test_a_dict_shaped_registry_is_read_not_called_empty(self, request, monkeypatch, tmp_path): + """Both registry shapes ship. A reader that knows one calls half the fleet empty. + + Also a mutant's find: making the dict branch return `[]` passed every + test, because every world I had written used the list shape. A guard + that reads only one shape would skip on a real installation of the + other and call it "no fleet here". + """ + root = self._world( + tmp_path, + rows={"memory": {"name": "memory", "path": "src/aipass/memory"}}, + residents=True, + roots=True, + external_citizen=True, + dict_shaped=True, + ) + self._point_both_lanes_at(monkeypatch, root) + + assert self._resolved(request, "live_all_tiers") == root diff --git a/src/aipass/memory/tests/test_plans_processor.py b/src/aipass/memory/tests/test_plans_processor.py index 3803e348a..5998ba744 100644 --- a/src/aipass/memory/tests/test_plans_processor.py +++ b/src/aipass/memory/tests/test_plans_processor.py @@ -11,10 +11,10 @@ Covers: from aipass.memory.apps.handlers.intake.plans_processor import process_plans """ +import hashlib import json import subprocess import sys -from pathlib import Path from unittest.mock import MagicMock, patch @@ -23,6 +23,11 @@ # --------------------------------------------------------------------------- +def _sha(path): + """The content hash the manifest records — spelled here so fixtures cannot drift from it.""" + return hashlib.sha256(path.read_text(encoding="utf-8").encode("utf-8")).hexdigest() + + def _import_plans_processor(monkeypatch): """Import plans_processor with mocked dependencies.""" mock_memory_files = MagicMock() @@ -161,6 +166,73 @@ def test_empty_text(self, monkeypatch): # =========================================================================== +class TestUnfilledPlaceholderSectionsAreNotVectorized: + """@flow's proposal, 2026-08-30, built as proposed and measured before shipping. + + A plan template ships sections the author is meant to fill in. Unfilled, the + body is nothing but the bracketed prompt — `[What do you want to achieve?]` + — and vectorizing it stores a question the template asked, attributed to a + plan that never answered it. + + NARROW ON PURPOSE. @flow's own plan-level heuristic (`is_template_content`) + was retired after it false-positived on real-but-minimal FPLANs and destroyed + the file, the registry row and the archive together. This is chunk-level, so + the worst case is a dropped empty section rather than a lost plan — and the + rule only fires when EVERY content line of the body is bracketed. One line of + real prose anywhere and the chunk is kept. + + Measured against the live collection before building: 452 of 8,433 vectors + (5.4%) match, and ZERO of them contain unbracketed prose. Not the ~27% @flow + estimated — most of that redundancy is identical FILLED template prose + ('## Agent Preparation', '## Notepad', '## Listen'), which is real content + and stays. + """ + + def test_an_unfilled_section_is_dropped(self, monkeypatch): + mod = _import_plans_processor(monkeypatch) + text = "### Goal\n[What do you want to achieve? Specific end state.]\n" + assert mod._chunk_plan_text(text, "p.md") == [] + + def test_a_trailing_horizontal_rule_does_not_save_it(self, monkeypatch): + """The live shape: the two biggest blocks both end in a `---` separator.""" + mod = _import_plans_processor(monkeypatch) + text = "## Notes\n\n[Working notes, issues encountered, decisions made]\n\n---\n" + assert mod._chunk_plan_text(text, "p.md") == [] + + def test_one_line_of_real_prose_keeps_the_whole_section(self, monkeypatch): + mod = _import_plans_processor(monkeypatch) + text = ( + "### Goal\n" + "[What do you want to achieve? Specific end state.]\n" + "Actually we want the rollover valve to stop refusing today's entries.\n" + ) + chunks = mod._chunk_plan_text(text, "p.md") + assert len(chunks) == 1 + assert "rollover valve" in chunks[0]["text"] + + def test_a_filled_section_is_untouched(self, monkeypatch): + mod = _import_plans_processor(monkeypatch) + text = "## Summary\n\nThe declared-roots anchor shipped and the fleet reads 28 citizens.\n" + chunks = mod._chunk_plan_text(text, "p.md") + assert len(chunks) == 1 + + def test_a_section_that_is_only_a_rule_or_blank_is_not_called_a_placeholder(self, monkeypatch): + """Absence of content is not the same as an unfilled prompt. + + A body with no content lines at all is already dropped by the length + gate. Routing it through the placeholder rule instead would make the + rule's own log and meaning wrong about why it went. + """ + mod = _import_plans_processor(monkeypatch) + assert mod._is_placeholder_only("## Notes\n\n---\n") is False + assert mod._is_placeholder_only("## Notes\n\n") is False + + def test_markdown_link_syntax_is_not_a_placeholder(self, monkeypatch): + """`[text](url)` opens with a bracket and is real content.""" + mod = _import_plans_processor(monkeypatch) + assert mod._is_placeholder_only("## Refs\n[the seedgo audit](./audit.md)\n") is False + + class TestManifest: """Test _load_manifest and _save_manifest.""" @@ -315,40 +387,40 @@ def test_store_vectors_exception(self, monkeypatch): class TestFindRepoRoot: - """Test _find_repo_root function.""" + """This lane no longer walks — it delegates to ``handlers/repo_root.py``. - def test_find_repo_root_with_registry(self, monkeypatch, tmp_path): - mod = _import_plans_processor(monkeypatch) - # Create a fake registry file - (tmp_path / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") - sub = tmp_path / "a" / "b" / "c" - sub.mkdir(parents=True) - fake_file = sub / "plans_processor.py" - fake_file.write_text("", encoding="utf-8") + THE TEST THAT USED TO LIVE HERE ASSERTED THE DEFECT. ``test_find_repo_root_ + falls_back_to_cwd`` pinned ``result == Path.cwd()``, so the exact construct + that took CI down twice on 2026-08-31 had a green test standing guard over + it. Both are rewritten rather than deleted: what a suite used to guarantee + is worth more in the record than a clean diff, and a reversed pin says out + loud that the contract changed on purpose. - # Patch __file__ to be inside tmp_path tree - monkeypatch.setattr(mod, "__file__", str(fake_file)) + The walk itself is pinned once, in ``test_repo_root.py``. Pinning it again + per lane would recreate the ten-copies problem in the tests. + """ - # Re-call _find_repo_root which reads __file__ at module level; - # but the function uses Path(__file__) inside, so we need to patch the - # function's reference to __file__. We do this by calling it after - # monkeypatching the module's __file__. - result = mod._find_repo_root() + def test_it_delegates_instead_of_walking_itself(self, monkeypatch, tmp_path): + """A private walk here is how the first cure missed nine other files.""" + mod = _import_plans_processor(monkeypatch) + seen: list[dict] = [] + monkeypatch.setattr( + mod.repo_root, + "find_repo_root", + lambda *args, **kwargs: (seen.append(kwargs), tmp_path)[1], + ) - assert result == tmp_path + assert mod._find_repo_root() == tmp_path + assert seen and seen[0].get("caller") == "plans_processor", ( + f"the lane did not name itself to the shared resolver: {seen}" + ) - def test_find_repo_root_falls_back_to_cwd(self, monkeypatch, tmp_path): + def test_it_never_returns_the_process_directory(self, monkeypatch, tmp_path): + """The reversal. Standing somewhere must not change where the code lives.""" mod = _import_plans_processor(monkeypatch) - # Point __file__ to a location with no registry - nowhere = tmp_path / "nowhere" / "file.py" - nowhere.parent.mkdir(parents=True) - nowhere.write_text("", encoding="utf-8") - monkeypatch.setattr(mod, "__file__", str(nowhere)) monkeypatch.chdir(tmp_path) - result = mod._find_repo_root() - - assert result == Path.cwd() + assert mod._find_repo_root() != tmp_path class TestGetMemoryPython: @@ -390,6 +462,175 @@ def test_falls_back_to_sys_executable(self, monkeypatch, tmp_path): # =========================================================================== +class TestTheManifestKeysOnContentNotOnlyOnAName: + """@flow's finding, 2026-08-30: a restored plan is never re-vectorized. + + `process_plans` skipped any file whose NAME appeared in the manifest. Restore + puts the plan file back but nothing removes its manifest row, so when that + plan is genuinely closed later its FINAL content is silently never stored and + the collection keeps only its pre-restore text. Three live cases, all APLANs + @devpulse restored after a mistaken close. + + @flow offered two seams: an entry point they call on restore, or keying the + manifest on content. Content keying wins because it needs nothing from them + and it is self-healing — it also covers a plan edited after close, which the + callback seam would still miss. + + Measured before choosing the migration: 491 manifest rows, 488 files present, + and ZERO present files modified after they were processed. So backfilling a + hash for a legacy row cannot skip a change that already happened, and the + mtime guard below covers one arriving later. + """ + + def _mock_config(self, monkeypatch, mod, plans_dir): + mock_cl = MagicMock() + mock_cl.section.return_value = { + "enabled": True, + "path": str(plans_dir), + "supported_extensions": [".md"], + "collection_name": "flow_plans", + } + monkeypatch.setattr(mod, "config_loader", mock_cl) + + def _stub_pipeline(self, monkeypatch, mod, seen): + monkeypatch.setattr( + mod, "_embed_texts", lambda texts, timeout=120: {"success": True, "embeddings": [[0.1]] * len(texts)} + ) + monkeypatch.setattr( + mod, + "_store_vectors", + lambda emb, texts, metas, coll: seen.append(metas[0]["source_file"]) or {"success": True}, + ) + + def test_changed_content_reprocesses_even_though_the_name_is_known(self, monkeypatch, tmp_path): + mod = _import_plans_processor(monkeypatch) + plans = tmp_path / "plans" + plans.mkdir() + plan = plans / "APLAN-0013_branch_audit_api_2026-08-13.md" + plan.write_text("## Summary\n\nthe text as it was before the restore\n", encoding="utf-8") + manifest_path = tmp_path / ".plans_processed.json" + monkeypatch.setattr(mod, "_PROCESSED_MANIFEST", manifest_path) + monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) + self._mock_config(monkeypatch, mod, plans) + seen = [] + self._stub_pipeline(monkeypatch, mod, seen) + + first = mod.process_plans() + assert first["files_processed"] == 1, first + + # Unchanged: must NOT re-embed. + seen.clear() + assert mod.process_plans()["files_processed"] == 0 + assert seen == [] + + # Restored with different content, same name — this is the defect. + plan.write_text("## Summary\n\nthe FINAL text, written after the restore\n", encoding="utf-8") + seen.clear() + again = mod.process_plans() + + assert again["files_processed"] == 1, "a restored plan's final content was never vectorized" + assert seen == [plan.name] + + def test_a_newly_processed_plan_records_its_content_not_just_a_time(self, monkeypatch, tmp_path): + """The write side, pinned separately from the read side. + + A mutant reverting `_manifest_entry` to a bare timestamp survived every + other test here: the legacy read path is tolerant enough that the next + run still answered "not stale" via mtime. So the row would quietly go + back to naming a time instead of a content, and the restore defect would + return for every plan processed from then on — invisibly, because + nothing asserted what the row actually said. + """ + mod = _import_plans_processor(monkeypatch) + plans = tmp_path / "plans" + plans.mkdir() + plan = plans / "FPLAN-9001_new_plan_2026-08-30.md" + plan.write_text("## Summary\n\nA plan with enough real content to make a chunk.\n", encoding="utf-8") + manifest_path = tmp_path / ".plans_processed.json" + monkeypatch.setattr(mod, "_PROCESSED_MANIFEST", manifest_path) + monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) + self._mock_config(monkeypatch, mod, plans) + self._stub_pipeline(monkeypatch, mod, []) + + assert mod.process_plans()["files_processed"] == 1 + + entry = json.loads(manifest_path.read_text(encoding="utf-8"))[plan.name] + assert isinstance(entry, dict), f"row is not content-keyed: {entry!r}" + assert entry["content_sha256"] == _sha(plan) + assert entry["processed_at"] + + def test_a_legacy_row_is_backfilled_not_re_embedded(self, monkeypatch, tmp_path): + """491 rows carry a bare timestamp. Re-embedding them all would be a storm. + + The migration records what the row was always missing — the hash — and + does not pay for embeddings it has no reason to believe are stale. + """ + mod = _import_plans_processor(monkeypatch) + plans = tmp_path / "plans" + plans.mkdir() + plan = plans / "FPLAN-0208_dashboard_count_test_plan_2026-05-10.md" + plan.write_text("## Summary\n\nunchanged since the day it was processed\n", encoding="utf-8") + manifest_path = tmp_path / ".plans_processed.json" + # The legacy shape, exactly as it sits on disk today: a bare ISO string. + manifest_path.write_text(json.dumps({plan.name: "2999-01-01T00:00:00"}), encoding="utf-8") + monkeypatch.setattr(mod, "_PROCESSED_MANIFEST", manifest_path) + monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) + self._mock_config(monkeypatch, mod, plans) + seen = [] + self._stub_pipeline(monkeypatch, mod, seen) + + result = mod.process_plans() + + assert result["files_processed"] == 0, "a legacy row with unchanged content must not re-embed" + assert seen == [] + entry = json.loads(manifest_path.read_text(encoding="utf-8"))[plan.name] + assert isinstance(entry, dict) and entry.get("content_sha256"), entry + + def test_a_legacy_row_whose_file_is_newer_than_its_row_reprocesses(self, monkeypatch, tmp_path): + """The safety net for the case the one-time measurement cannot cover. + + A legacy row carries no hash, so 'unchanged' is a belief, not a fact. If + the file was written AFTER the row was recorded, that belief is wrong — + which is exactly what a restore does to a plan. + """ + mod = _import_plans_processor(monkeypatch) + plans = tmp_path / "plans" + plans.mkdir() + plan = plans / "APLAN-0017_branch_audit_commons_2026-08-13.md" + plan.write_text("## Summary\n\nrestored, and the row predates this file\n", encoding="utf-8") + manifest_path = tmp_path / ".plans_processed.json" + manifest_path.write_text(json.dumps({plan.name: "2001-01-01T00:00:00"}), encoding="utf-8") + monkeypatch.setattr(mod, "_PROCESSED_MANIFEST", manifest_path) + monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) + self._mock_config(monkeypatch, mod, plans) + seen = [] + self._stub_pipeline(monkeypatch, mod, seen) + + assert mod.process_plans()["files_processed"] == 1 + assert seen == [plan.name] + + def test_a_manifest_row_whose_file_is_gone_is_left_alone(self, monkeypatch, tmp_path): + """The three live cases are rows for files that LEFT processed_plans. + + Pruning them would re-vectorize the whole plan from scratch the moment it + came back, discarding nothing but paying for everything. The row is not + the problem; trusting the row's NAME was. Left in place on purpose. + """ + mod = _import_plans_processor(monkeypatch) + plans = tmp_path / "plans" + plans.mkdir() + manifest_path = tmp_path / ".plans_processed.json" + gone = "APLAN-0018_branch_audit_aipass_2026-08-13.md" + manifest_path.write_text(json.dumps({gone: "2026-05-10T13:03:05"}), encoding="utf-8") + monkeypatch.setattr(mod, "_PROCESSED_MANIFEST", manifest_path) + monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) + self._mock_config(monkeypatch, mod, plans) + + mod.process_plans() + + assert gone in json.loads(manifest_path.read_text(encoding="utf-8")) + + class TestProcessPlans: """Test process_plans main entry point.""" @@ -461,7 +702,15 @@ def test_process_plans_all_already_processed(self, monkeypatch, tmp_path): ) monkeypatch.setattr(mod, "_find_repo_root", lambda: tmp_path) manifest_path = tmp_path / ".plans_processed.json" - manifest_path.write_text(json.dumps({"done.md": "2026-01-01T00:00:00"}), encoding="utf-8") + # The row must POSTDATE the file. Written as 2026-01-01 against a file + # created moments ago, this fixture was the restore shape by accident — + # a plan whose content is newer than the row claiming to have processed + # it — and it only read as "already processed" while the check ignored + # everything but the name. + manifest_path.write_text( + json.dumps({"done.md": {"processed_at": "2026-01-01T00:00:00", "content_sha256": _sha(plan_file)}}), + encoding="utf-8", + ) monkeypatch.setattr(mod, "_PROCESSED_MANIFEST", manifest_path) result = mod.process_plans() diff --git a/src/aipass/memory/tests/test_repo_root.py b/src/aipass/memory/tests/test_repo_root.py new file mode 100644 index 000000000..5a6e9df28 --- /dev/null +++ b/src/aipass/memory/tests/test_repo_root.py @@ -0,0 +1,1469 @@ +"""Pins for ``handlers/repo_root.py`` — one repo-root answer, never the cwd. + +WHY A WHOLE MODULE FOR ONE FUNCTION +----------------------------------- +@drone reported the ``Path.cwd()`` fallback in ``registry_scope`` and it was +cured the same hour. CI went red again within that hour on ``detector.py``: +byte-identical function, one file over, one of TEN copies. The subprocess pin +written for the first fix is what caught it — but only because CI runs a bare +checkout where the fallback is actually reached. On a developer machine the +walk finds the live registry and the defect is invisible. + +So the pins here come in two species, deliberately: + + * BEHAVIOURAL — what the function does when the fallback IS taken. These run + everywhere, because they hand the walk a directory with no registry above + it rather than waiting for the machine to be bare. + * STRUCTURAL — that no lane keeps a private copy of the answer. This is the + pin that would have caught ``detector.py`` before CI did, and it is the + only one that makes a third round impossible. A test that can only fail in + an environment we do not run locally is not a guard, it is a report. +""" + +import importlib +import inspect +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +from aipass.memory.apps.handlers import repo_root as rr +from aipass.memory.tests.dead_cwd import ( + ACCESSOR_SHAPE, + DEAD_CWD_WORLD, + DELETE_CWD_WORLD, + POSIX_EMULATED_WORLD, + REALPATH_DENIED_WORLD, + WINDOWS_EMULATED_WORLD, + WINDOWS_REALPATH_WORLD, +) + +# Every .py in the branch's own source, minus archives (kept deliberately as +# written) and caches. The sweep must read the tree, not a list someone +# maintains by hand — a hand-maintained list is how the tenth copy hid. +_APPS = Path(rr.__file__).resolve().parent.parent +_SOURCES = sorted( + path for path in _APPS.rglob("*.py") if ".archive" not in path.parts and "__pycache__" not in path.parts +) + +# The one other species of cwd read in this tree, and it is not this defect: +# "where was the caller standing" is a QUESTION ABOUT THE CALLER, and cwd is +# the right answer to it. Named here so the sweep refuses everything else. +_CALLER_CWD_SITES = { + "detector.py", + "memory_watcher.py", +} + + +def _src_root() -> str: + """The importable source root, for subprocesses that must not inherit sys.path.""" + return str(Path(rr.__file__).resolve().parents[5]) + + +def _prose_lines(text: str) -> set[int]: + """Line numbers occupied by string literals — docstrings included. + + The sweeps below convict a file for CONTAINING a construct, and every + docstring that explains this defect has to spell it. Reading the parse tree + rather than guessing at quote characters means a file can describe the bug + it cured without being convicted of having it. + """ + import ast + + covered: set[int] = set() + for node in ast.walk(ast.parse(text)): + if isinstance(node, ast.Constant) and isinstance(node.value, str) and node.end_lineno: + covered.update(range(node.lineno, node.end_lineno + 1)) + return covered + + +def _cwd_reads(text: str) -> list[int]: + """Line numbers where ``Path.cwd()`` appears in CODE, not in prose or a comment. + + Extracted so it can be measured against a known sample. A filter is an + instrument, and an instrument nothing checks can be silenced without + anything going red — a mutation that made ``_prose_lines`` return every + line left the whole sweep vacuous and the suite green. + """ + prose = _prose_lines(text) + return [ + number + for number, line in enumerate(text.splitlines(), start=1) + if "Path.cwd()" in line and not line.lstrip().startswith("#") and number not in prose + ] + + +# --------------------------------------------------------------------------- +# BEHAVIOURAL — what the fallback resolves to +# --------------------------------------------------------------------------- + + +class TestTheFallbackIsDerivedFromTheSourceTree: + """A registry-less world resolves to where the CODE is, not where the caller is.""" + + def test_a_registryless_walk_returns_the_source_root(self, tmp_path): + """No AIPASS_REGISTRY.json above tmp_path, so the fallback is exercised.""" + assert rr.find_repo_root(tmp_path) == rr.SOURCE_ROOT + + def test_the_answer_does_not_depend_on_where_the_caller_stands(self, tmp_path, monkeypatch): + """The QUIET defect: two callers in different directories got different roots.""" + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + from_elsewhere = rr.find_repo_root(tmp_path) + monkeypatch.chdir(tmp_path) + assert rr.find_repo_root(tmp_path) == from_elsewhere + + def test_the_source_root_is_the_parent_of_src(self): + """SOURCE_ROOT is the checkout, derived from the layout `src/` guarantees.""" + assert (rr.SOURCE_ROOT / "src").is_dir() + + def test_a_real_registry_above_the_start_wins(self, tmp_path): + """The fallback is a last resort, not a shortcut past the walk.""" + (tmp_path / rr.CORE_REGISTRY).write_text("{}", encoding="utf-8") + nested = tmp_path / "a" / "b" + nested.mkdir(parents=True) + assert rr.find_repo_root(nested) == tmp_path + + def test_the_marker_is_a_parameter_not_a_constant(self, tmp_path): + """Callers that mark a root with a different file get the same walk.""" + (tmp_path / "OTHER_MARKER.json").write_text("{}", encoding="utf-8") + nested = tmp_path / "a" + nested.mkdir() + assert rr.find_repo_root(nested, marker="OTHER_MARKER.json") == tmp_path + + def test_the_fallback_says_so_out_loud(self, tmp_path, caplog): + """A fallback nobody can see is how the next one survives.""" + with caplog.at_level("WARNING"): + rr.find_repo_root(tmp_path, caller="a_named_lane") + assert any("a_named_lane" in record.message for record in caplog.records), ( + f"the fallback did not name its caller: {[r.message for r in caplog.records]}" + ) + + def test_a_successful_walk_is_silent(self, tmp_path, caplog): + """Only the fallback is news. A found registry is the normal case.""" + (tmp_path / rr.CORE_REGISTRY).write_text("{}", encoding="utf-8") + with caplog.at_level("WARNING"): + rr.find_repo_root(tmp_path) + assert not caplog.records, [r.message for r in caplog.records] + + +class TestTheAuditLineCanNeverTakeAnImportDown: + """Four callers resolve at MODULE level, so this write happens during their import. + + ``repo_root`` imports ``json_handler`` INSIDE ``_record_fallback`` — a + module-level edge would be a cycle now that ``json_handler`` imports + ``module_file`` from here — so the patch has to land on the object that + function-local import will actually find. That is whatever ``sys.modules`` + holds at call time, which under this suite's infrastructure mock is not the + module a plain ``from ... import json_handler`` at the top of this file + binds. Patching a name the code under test never reads is the quietest way + to write a test that cannot fail. + """ + + @staticmethod + def _the_module_the_fallback_will_import(): + return sys.modules["aipass.memory.apps.handlers.json.json_handler"] + + def test_a_failing_log_operation_does_not_propagate(self, tmp_path, monkeypatch): + """A diagnostic write must not become the crash it was diagnosing.""" + + def explode(*_args, **_kwargs): + raise OSError("read-only filesystem") + + monkeypatch.setattr(self._the_module_the_fallback_will_import(), "log_operation", explode) + assert rr.find_repo_root(tmp_path) == rr.SOURCE_ROOT + + def test_the_fallback_is_still_recorded_when_it_can_be(self, tmp_path, monkeypatch): + """Defensive does not mean absent — the operation is logged on the happy path.""" + seen = [] + monkeypatch.setattr( + self._the_module_the_fallback_will_import(), "log_operation", lambda *a, **k: seen.append((a, k)) + ) + rr.find_repo_root(tmp_path, caller="a_named_lane") + assert seen, "the fallback was taken and nothing was recorded" + assert seen[0][0][0] == "repo_root_fallback" + + +# --------------------------------------------------------------------------- +# STRUCTURAL — no lane keeps a private copy +# --------------------------------------------------------------------------- + + +class TestNoLaneKeepsAPrivateCopyOfTheAnswer: + """The sweep pin. This is the one that catches the eleventh copy.""" + + def test_only_repo_root_implements_the_walk(self): + """A second implementation is how the first cure missed nine files. + + Delegating wrappers are fine and expected — several lanes keep a local + ``_find_repo_root`` name because tests patch it. What is refused is a + second BODY: a wrapper is one line that calls this module. + """ + offenders = [] + for path in _SOURCES: + if path == Path(rr.__file__).resolve(): + continue + text = path.read_text(encoding="utf-8") + for match in re.finditer(r"def _?find_repo_root\b[^\n]*\n((?:[ \t]+[^\n]*\n|\n)*)", text): + body = match.group(1) + if "repo_root.find_repo_root" not in body and "rr.find_repo_root" not in body: + offenders.append(path.relative_to(_APPS)) + assert not offenders, ( + f"{len(offenders)} lane(s) implement the walk themselves instead of delegating: {offenders}" + ) + + def test_no_lane_falls_back_to_the_process_directory(self): + """`return Path.cwd()` is the exact construct that broke CI twice.""" + offenders = [ + str(path.relative_to(_APPS)) + for path in _SOURCES + if re.search(r"^\s*return Path\.cwd\(\)", path.read_text(encoding="utf-8"), re.MULTILINE) + ] + assert not offenders, f"process-directory fallback still present in: {offenders}" + + def test_the_only_remaining_cwd_reads_are_the_caller_cwd_species(self): + """Not a ban on cwd — a ban on cwd standing in for "where does the code live". + + `AIPASS_CALLER_CWD` reads are a question ABOUT THE CALLER, and cwd is + the correct answer to that question. They are named, so a new one has + to be argued for rather than blend in. + + PROSE IS NOT CODE. The docstrings that explain this defect necessarily + SPELL it, and a sweep that convicts a file for describing the bug it + fixed would be uncurable — the only way to pass would be to stop + explaining. String and comment lines are excluded structurally, via the + parse tree, not by guessing at quote characters. + """ + offenders = [] + for path in _SOURCES: + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + for number in _cwd_reads(text): + if "AIPASS_CALLER_CWD" in lines[number - 1]: + if path.name not in _CALLER_CWD_SITES: + offenders.append(f"{path.relative_to(_APPS)}:{number} (unlisted caller-cwd site)") + continue + if path == Path(rr.__file__).resolve(): + continue + offenders.append(f"{path.relative_to(_APPS)}:{number}") + assert not offenders, f"unexplained Path.cwd() reads: {offenders}" + + +class TestTheSweepCanStillSee: + """The positive control. A filter nothing checks can be silenced quietly. + + A mutation that made ``_prose_lines`` cover every line left the sweep above + unable to flag anything and the whole suite green — the instrument reported + its own blindness as a clean bill of health. Same species as a guard fixture + that skips: the assertion existed and could no longer fail. + """ + + _SAMPLE = ( + '"""A docstring that mentions Path.cwd() while explaining the defect."""\n' + "\n" + "# A comment mentioning Path.cwd() too.\n" + "def offender():\n" + " return Path.cwd()\n" + ) + + def test_it_finds_the_construct_in_code(self): + assert _cwd_reads(self._SAMPLE) == [5], ( + "the sweep cannot see a Path.cwd() sitting in plain code — it would pass over the real tree " + "for the same reason" + ) + + def test_it_does_not_convict_prose_or_comments(self): + assert 1 not in _cwd_reads(self._SAMPLE) + assert 3 not in _cwd_reads(self._SAMPLE) + + +# --------------------------------------------------------------------------- +# IMPORT-TIME — the crash species +# --------------------------------------------------------------------------- + +# The four lanes that resolve their root while being IMPORTED. For these the +# defect needs no call: `import` is the crash site, which is why the CI +# traceback ran through handlers/__init__ and never reached a test body. +_IMPORT_TIME_LANES = [ + "aipass.memory.apps.handlers.monitor.detector", + "aipass.memory.apps.handlers.monitor.registry_scope", + "aipass.memory.apps.handlers.central_writer", + "aipass.memory.apps.handlers.templates.trinity_push", + "aipass.memory.apps.handlers.rollover.orchestrator", +] + +# --- BUILDING THE WORLD, TWICE, BECAUSE ONE WAY DOES NOT EXIST EVERYWHERE ---- +# +# The original recipe deletes the process's working directory. That is a REAL +# world on POSIX and an IMPOSSIBLE one on Windows: Windows holds a lock on the +# directory a process is standing in, so the rmdir raises PermissionError +# (WinError 32) and every pin built this way dies at SETUP rather than at its +# claim — which is how these arrived on the Windows CI leg. +# +# The condition being pinned is NOT "a directory was deleted". It is +# "``os.getcwd()`` raises", which is what ``Path.cwd()`` does underneath and the +# only thing this code can actually observe. Deleting the directory is one way +# to cause it; a disconnected network share or an ejected volume is another, and +# those DO happen on Windows. So the primary construction denies ``getcwd`` +# directly — the failure itself rather than one cause of it — and runs on every +# platform. +# +# Injecting it is not faking the world, on the same reasoning that lets the +# marker be denied in-process: the interpreter raises the real exception from +# the real call site. And the claim does not rest on that argument alone — +# ``TestBothConstructionsAgree`` runs BOTH recipes on POSIX and asserts they +# produce the same outcome, which is what licenses using the injected one where +# the real one cannot be built. +_DENY_CWD = DEAD_CWD_WORLD + +_DELETE_CWD = DELETE_CWD_WORLD + +_WINDOWS_CANNOT_DELETE_ITS_OWN_CWD = pytest.mark.skipif( + sys.platform == "win32", + reason=( + "Windows locks a process's working directory, so it cannot be deleted from inside — " + "this WORLD cannot be built here by this construction (PermissionError WinError 32 at " + "the rmdir, before the pin's own claim is ever reached). The same defect is pinned on " + "every platform by the getcwd-denial construction, and the two are proved equivalent " + "on POSIX by TestBothConstructionsAgree." + ), +) + + +class TestEveryImportTimeLaneSurvivesADeadWorkingDirectory: + """Importing must never raise, in any world. Subprocess, because cwd is process-wide. + + Once the working directory is gone, EVERY ``Path.cwd()`` in the interpreter + raises — pytest's own included. Deleting the runner's cwd would take the + suite down with it, so the condition is created in a child. + + THE PIN JUDGES ONLY THIS BRANCH, and that distinction is the whole point of + running it. On a bare tree the import chain reaches @prax before it reaches + us: every handler in the fleet does ``logger = get_system_logger()`` at + module level, and prax's own ``config/load.py`` carries this exact species + — a repo-root walk whose last resort is ``Path.cwd()``. So a raw "did the + import survive" assertion measures two branches at once and reports prax's + line as memory's defect. + + THE BLOCKER IS CLEARED, AND SO THE ESCAPE HATCH IS GONE (2026-08-31, later + the same night). @prax swept eight sites of the same species and their fix + landed, so this reports a HARD failure again — verified here by rebuilding + the bare stand-in against their tree: 11/11 memory modules import clean in a + dead cwd with no substitution at all. + + An xfail that outlives its blocker is worse than no pin, because a + regression upstream would go on reporting itself as an expected failure. + What survives is the ATTRIBUTION: a crash outside this branch still fails, + and it fails naming the tree it happened in, because convicting ourselves of + somebody else's line is how a real defect gets fixed in the wrong place. + """ + + _NOT_OURS = "/aipass/prax/" + + @pytest.mark.parametrize("world", [_DENY_CWD, pytest.param(_DELETE_CWD, marks=_WINDOWS_CANNOT_DELETE_ITS_OWN_CWD)]) + @pytest.mark.parametrize("module", _IMPORT_TIME_LANES) + def test_importing_survives(self, module, world): + probe = f"import os, sys\nsys.path.insert(0, {_src_root()!r})\n{world}import {module}\nprint('OK')\n" + result = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True) + assert result.returncode == 0, ( + f"{module} could not be imported in a dead cwd. " + + ( + "THE CRASH IS NOT IN THIS BRANCH — it is in @prax, which was cured on 2026-08-31 " + "and has regressed. Route it there rather than editing memory.\n" + if self._NOT_OURS in result.stderr + else "" + ) + + result.stderr + ) + assert "OK" in result.stdout + + +class TestMemorysHalfIsCuredEvenWhereTheChainStillBreaks: + """The measurement that isolates this branch from the fleet's shared logger. + + The class above cannot prove memory is cured on a bare tree, because prax + raises first and an xfail proves nothing about us. Here prax's copy is + neutralised in the child — replaced, not caught — so what remains is + memory's tree alone. If any of these go red the defect IS ours. + + Neutralising rather than skipping matters: a skip when the world is + inconvenient is how a guard quietly stops guarding. This still runs, it + still asserts, and it names exactly what it held constant. + """ + + @pytest.mark.parametrize("world", [_DENY_CWD, pytest.param(_DELETE_CWD, marks=_WINDOWS_CANNOT_DELETE_ITS_OWN_CWD)]) + @pytest.mark.parametrize("module", _IMPORT_TIME_LANES) + def test_this_branch_imports_in_a_bare_world(self, module, world): + probe = ( + # The world FIRST. It no longer has to be — DEAD_CWD_WORLD patches + # the 3.10 accessor too, so import order stops deciding whether the + # world is hostile — but a probe that reads in the order it takes + # effect cannot quietly regrow the dependency. + f"{world}" + "import sys, pathlib\n" + f"sys.path.insert(0, {_src_root()!r})\n" + "import aipass.prax.apps.handlers.config.load as prax_load\n" + f"prax_load._find_repo_root = lambda: pathlib.Path({str(rr.SOURCE_ROOT)!r})\n" + f"import {module}\n" + "print('OK')\n" + ) + result = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True) + assert result.returncode == 0, ( + f"{module} could not be imported in a dead cwd with prax held constant — " + f"this one IS memory's:\n{result.stderr}" + ) + assert "OK" in result.stdout + + @pytest.mark.parametrize("module", _IMPORT_TIME_LANES) + def test_this_branch_imports_where_the_fallback_actually_runs(self, module): + """@drone's technique, adopted: make the world hostile instead of waiting for it. + + The pins above cannot reach the fallback on a developer machine — a real + registry sits above these files, the walk succeeds, and the branch that + broke CI twice is never executed. @drone hit the mirror of this the same + night (a sweep asserting "the checkout is a project root", true here and + false on a bare runner, red within two hours) and their cure is better + than my caveat: deny the marker in-process so the fallback leg RUNS, + on any machine, bare or not. + + So the registry is made invisible before the import. With the cure in + place these pass; with ``Path.cwd()`` restored they raise + ``FileNotFoundError`` right here, on this laptop, with no CI required. + That is the difference between a pin with teeth and a pin that files a + report from an environment we do not run. + """ + probe = ( + f"{_DENY_CWD}" + "import sys, pathlib\n" + f"sys.path.insert(0, {_src_root()!r})\n" + "import aipass.prax.apps.handlers.config.load as prax_load\n" + f"prax_load._find_repo_root = lambda: pathlib.Path({str(rr.SOURCE_ROOT)!r})\n" + # every registry on this machine becomes invisible, so the walk in + # every lane runs off the end and the fallback is what answers + "_real_exists = pathlib.Path.exists\n" + "pathlib.Path.exists = lambda self, *a, **k: (\n" + " False if self.name.endswith('_REGISTRY.json') else _real_exists(self, *a, **k)\n" + ")\n" + f"import {module} as target\n" + "print('OK')\n" + ) + result = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True) + assert result.returncode == 0, ( + f"{module} took the fallback and died — this is the CI defect reproduced locally:\n{result.stderr}" + ) + assert "OK" in result.stdout + + +class TestTheDeadCwdProbeIsAHonestInstrument: + """The pins above prove nothing unless the world they build really is hostile. + + Both constructions get a positive control, because the whole argument for + using the injected one on Windows is that it produces the SAME condition — + and an unchecked instrument is what let a blinded filter report a vacuous + sweep as green earlier tonight. + """ + + _CHECK = ( + "import pathlib\n" + "try:\n" + " pathlib.Path.cwd()\n" + " print('NO_RAISE')\n" + "except FileNotFoundError:\n" + " print('RAISED')\n" + ) + + def test_denying_getcwd_makes_reading_the_cwd_raise(self): + """The portable construction. Runs on Windows, where the other cannot.""" + result = subprocess.run([sys.executable, "-c", _DENY_CWD + self._CHECK], capture_output=True, text=True) + assert "RAISED" in result.stdout, f"{result.stdout}{result.stderr}" + + @_WINDOWS_CANNOT_DELETE_ITS_OWN_CWD + def test_deleting_the_cwd_makes_reading_the_cwd_raise(self): + """The real world, where the platform allows it to be built.""" + result = subprocess.run([sys.executable, "-c", _DELETE_CWD + self._CHECK], capture_output=True, text=True) + assert "RAISED" in result.stdout, f"{result.stdout}{result.stderr}" + + +class TestBothConstructionsAgree: + """What licenses using the injected world where the real one cannot be built. + + Windows locks a process's working directory, so ``_DELETE_CWD`` cannot run + there at all — it raises PermissionError at the rmdir, before any claim is + reached. That is not a reason to stop pinning the defect on Windows; it is a + reason to pin the CONDITION (``os.getcwd()`` raises) rather than one cause + of it. + + But "these two are equivalent" is an argument, and arguments belong in + tests. On POSIX both worlds can be built, so both are built and compared — + against the cured code AND against the defect restored. If they ever stop + agreeing, the Windows coverage is resting on a claim that has expired. + """ + + _ASK = ( + "import sys, pathlib\n" + f"sys.path.insert(0, {_src_root()!r})\n" + "_real = pathlib.Path.exists\n" + "pathlib.Path.exists = lambda self, *a, **k: (\n" + " False if self.name.endswith('_REGISTRY.json') else _real(self, *a, **k)\n" + ")\n" + "import aipass.prax.apps.handlers.config.load as prax_load\n" + "prax_load._find_repo_root = lambda: pathlib.Path(__file__ if False else '/')\n" + "from aipass.memory.apps.handlers import repo_root\n" + "print('ANSWER', repo_root.find_repo_root())\n" + ) + + @staticmethod + def _run(world): + """Ask the resolver the same question in *world* and report what came back.""" + return subprocess.run( + [sys.executable, "-c", world + TestBothConstructionsAgree._ASK], + capture_output=True, + text=True, + ) + + @_WINDOWS_CANNOT_DELETE_ITS_OWN_CWD + def test_the_two_worlds_produce_the_same_answer(self): + denied, deleted = self._run(_DENY_CWD), self._run(_DELETE_CWD) + assert denied.returncode == 0, denied.stderr + assert deleted.returncode == 0, deleted.stderr + assert denied.stdout.strip() == deleted.stdout.strip(), ( + f"the injected world and the real one disagree — the Windows coverage rests on them " + f"agreeing.\ndenied: {denied.stdout!r}\ndeleted: {deleted.stdout!r}" + ) + + +class TestAFilenameIsNotAnExistsCall: + """``exists_exactly`` — the half of the pair with no glob to warn a reader. + + @seedgo published it as their own discriminator's blind spot on 2026-08-31: + a cased LITERAL folds too, so ``(dir / "AIPASS_REGISTRY.json").exists()`` + returns True for a file actually named ``aipass_registry.json`` on Windows + and on a macOS default volume. + + It lives here rather than at each caller because ``find_repo_root`` IS such + a check, run at module level in four modules. A folded bait file accepted as + THE REPO ROOT hands every writer downstream a tree nobody chose — which is + the quiet defect this module was built to end, arriving through a door it + did not cover. + """ + + def test_the_injected_world_really_folds(self, tmp_path, case_insensitive_exists): + """Positive control. A fold that does not fold makes every pin below vacuous.""" + (tmp_path / "aipass_registry.json").write_text("{}", encoding="utf-8") + + assert (tmp_path / "AIPASS_REGISTRY.json").exists(), "the emulation is not folding" + + def test_a_folded_name_is_not_the_name_that_was_asked_for(self, tmp_path, case_insensitive_exists): + (tmp_path / "aipass_registry.json").write_text("{}", encoding="utf-8") + + assert rr.exists_exactly(tmp_path / "AIPASS_REGISTRY.json") is False + + def test_the_exact_name_still_answers_yes(self, tmp_path, case_insensitive_exists): + """The guard must not refuse the file it exists to find.""" + (tmp_path / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") + + assert rr.exists_exactly(tmp_path / "AIPASS_REGISTRY.json") is True + + def test_absent_is_absent_without_listing_anything(self, tmp_path): + assert rr.exists_exactly(tmp_path / "AIPASS_REGISTRY.json") is False + + def test_an_unlistable_parent_trusts_exists_rather_than_inventing_a_refusal(self, tmp_path, monkeypatch): + """The documented fallback, pinned so it is a decision and not an accident. + + This is a READ anchor and today's behaviour is ``exists()`` alone. + Refusing a file that is demonstrably there because its directory could + not be enumerated would be a new failure invented by the guard. + """ + target = tmp_path / "AIPASS_REGISTRY.json" + target.write_text("{}", encoding="utf-8") + + def _denied(*args, **kwargs): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(rr.os, "scandir", _denied) + + assert rr.exists_exactly(target) is True + + def test_the_repo_root_walk_does_not_anchor_on_a_folded_bait_file(self, tmp_path, case_insensitive_exists): + """The whole reason this guard is in THIS module. + + A directory carrying a lowercase ``aipass_registry.json`` must not be + answered as the repo root. Every writer in this tree resolves through + here, and a guessed root is a write into somebody else's directory. + """ + bait = tmp_path / "someone_elses_repo" + (bait / "deep").mkdir(parents=True) + (bait / "aipass_registry.json").write_text("{}", encoding="utf-8") + + found = rr.find_repo_root(bait / "deep", caller="test") + + assert found != bait + assert found == rr.SOURCE_ROOT, "the fallback is the source tree, never a folded guess" + + def test_the_walk_still_finds_a_correctly_spelled_marker(self, tmp_path, case_insensitive_exists): + """The positive half: the guard must not blind the walk it protects.""" + real = tmp_path / "a_real_repo" + (real / "deep").mkdir(parents=True) + (real / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") + + assert rr.find_repo_root(real / "deep", caller="test") == real + + +class TestTheFilterHasOneImplementationForFourWalks: + """``exactly_named`` moved here the day the third and fourth walks turned up. + + It was written in ``registry_scope`` for that module's two globs, with a + docstring claiming a third walk could not be written without it. @drone's + sweep and @seedgo's fleet discriminator then named two more in this tree — + ``detector`` and ``memory_watcher`` — both written years before the filter + existed. The claim was true about the future and said nothing about the + past, so the body moved to the module all four already import. + """ + + def test_registry_scope_delegates_rather_than_carrying_a_twin(self): + from aipass.memory.apps.handlers.monitor import registry_scope + + source = inspect.getsource(registry_scope._exactly_named) + + assert "repo_root.exactly_named" in source, "the ten-copy lesson, one package over" + assert "endswith" not in source.split('"""')[-1], "a second implementation is a second answer" + + @pytest.mark.parametrize( + "module_name", + [ + "aipass.memory.apps.handlers.monitor.detector", + "aipass.memory.apps.handlers.monitor.memory_watcher", + "aipass.memory.apps.handlers.monitor.registry_scope", + ], + ) + def test_no_registry_glob_in_this_tree_is_left_unfiltered(self, module_name): + """The structural pin: catch it where it is WRITTEN, not only where it runs. + + Last night's lesson cost a second CI red — a cure that landed on one of + N identical sites while the rest kept the disease. This reads the source + rather than the behaviour, so a fifth walk added tomorrow is red on the + line it is typed on. + """ + source = inspect.getsource(importlib.import_module(module_name)) + + for number, line in enumerate(source.splitlines(), start=1): + if "glob(" not in line or "_REGISTRY.json" not in line: + continue + assert "exactly_named(" in line, f"{module_name}:{number} globs registries with no exact-case filter" + + +class TestTheDeniedWorldSurvivesEveryWayPathlibCallsIt: + """The 3.10 red, reproduced on whatever interpreter is running this. + + CI found it and none of us runs 3.10 locally, so the honest question was + whether this could be pinned here at all or only reported. It can: the + mechanism is plain Python, not a 3.10 feature. ``_NormalAccessor.getcwd = + os.getcwd`` stores a FUNCTION on a class, and a function reached through an + instance is a bound method on every version — so ``Path.cwd()`` handed the + accessor as ``self`` to a zero-argument lambda and got ``TypeError`` where + the pin expected ``FileNotFoundError``. + + ``ACCESSOR_SHAPE`` is those three lines and nothing else. Running the world + against it here is the same move as denying ``getcwd`` instead of deleting a + directory: pin the CONDITION (the call arrives bound) rather than the cause + (an interpreter that binds it). + """ + + _ASK = ( + "try:\n" + " _accessor.getcwd()\n" + " print('NO_RAISE')\n" + "except FileNotFoundError:\n" + " print('RAISED')\n" + "except TypeError as exc:\n" + " print('TYPEERROR', exc)\n" + ) + + def test_the_denial_answers_when_pathlib_calls_it_as_a_bound_method(self): + """Red on this laptop with the old ``lambda:`` spelling. That is the point.""" + result = subprocess.run( + [sys.executable, "-c", _DENY_CWD + ACCESSOR_SHAPE + self._ASK], capture_output=True, text=True + ) + + assert "RAISED" in result.stdout, f"{result.stdout}{result.stderr}" + + def test_the_world_patches_an_accessor_that_already_captured_the_real_getcwd(self): + """The defect CI could NOT see, and the more dangerous of the two. + + On 3.10 the accessor captures ``os.getcwd`` when ``pathlib`` is first + imported, so a probe importing pathlib BEFORE installing the denial gets + an accessor holding the REAL function — a world that is not hostile at + all. Four of this branch's probes were written that way. On 3.10 they + were passing while asserting nothing, and nothing in the output tells a + vacuous pin apart from a cured defect. + + A first draft of this pin just reordered the imports and asserted the + world still bit. It SURVIVED the mutant that removes the accessor patch, + because on 3.11+ there is no accessor and order genuinely does not + matter — a green light wired to nothing, in the test written to stop + exactly that. So the accessor is BUILT here, having captured the real + ``os.getcwd`` first, which is 3.10's situation reproduced rather than + described. + """ + pre_captured = ( + "import os, pathlib\n" + "class _NormalAccessor:\n" + " getcwd = os.getcwd\n" + "pathlib._NormalAccessor = _NormalAccessor\n" + "_accessor = _NormalAccessor()\n" + ) + + result = subprocess.run( + [sys.executable, "-c", pre_captured + _DENY_CWD + self._ASK], capture_output=True, text=True + ) + + assert "RAISED" in result.stdout, f"the world never reached the accessor: {result.stdout}{result.stderr}" + + def test_the_world_is_defined_once_for_the_whole_branch(self): + """Two files carried two spellings of one world, and both were wrong. + + The version bug was in both copies; only one of them was on the line CI + happened to run first. A world with two implementations is two worlds, + and the cheapest place to notice that is here. + """ + import aipass.memory.tests.test_residency_scope as residency + + assert residency.DEAD_CWD_WORLD is DEAD_CWD_WORLD + # Assembled rather than written out: a literal needle in the assertion + # makes this file its own first offender, which the first run proved by + # convicting this very line. + retyped = "getcwd" + " = " + "lambda" + for module in (residency, sys.modules[__name__]): + source = inspect.getsource(module) + assert retyped not in source, f"{module.__name__} retyped the world instead of importing it" + + +# Was a local copy, and the copy is what went red on Python 3.10: it patched +# ``os.path.realpath`` without patching the accessor 3.10's pathlib had already +# captured it into, so the probe below printed NO_RAISE and every pin under it +# was vacuous on that interpreter. The cure lives in ``dead_cwd.py`` beside the +# getcwd world that already carried it — which is where this constant should +# have been all along, since living apart from that docstring is how it came to +# repeat the exact trap the docstring describes. +_WINDOWS_REALPATH = WINDOWS_REALPATH_WORLD + + +class TestResolvingOwnFileIsACwdReadOnWindows: + """The Windows CI reds, and they were NOT the world being unbuildable. + + @devpulse's steer offered two honest outcomes — a guard in the code if the + frame is mine, or the deletion-recipe treatment if it is not. The frame was + mine, and then thirty-one more were. + + THE MECHANISM. ``ntpath.realpath`` computes ``os.getcwd()`` + UNCONDITIONALLY — not only for a relative path, the way ``posixpath`` does — + and ``Path.resolve()`` goes through it. So on Windows every + ``Path(__file__).resolve()`` is a working-directory read, and this branch + had thirty-two of them running at import time. CI showed the first + (``inspect.stack()`` in the handlers guard, which resolves every frame's + source file); behind it the traceback simply moved down. + + So the condition to inject is "resolving a path reads the cwd", which is a + property of the STDLIB on that platform and reproducible here in six lines. + Pinning "we are on Windows" would have pinned nothing runnable. + + WHAT THIS CLASS DOES NOT CLAIM. It holds the OTHER branches' copy of the + template-born guard constant, because ``prax/apps/handlers/__init__.py`` + carries the identical ``inspect.stack()`` defect and would crash first. That + is memory's half proved, and the fleet's half named rather than assumed — + routed to @spawn, who owns the template all eighteen copies came from. + """ + + _HOLD_OTHER_BRANCHES = ( + "import aipass.prax.apps.handlers as _prax_handlers\n" + "_prax_handlers._find_real_caller = lambda: (None, None)\n" + "import aipass.prax.apps.handlers.config.load as _prax_load\n" + f"_prax_load._find_repo_root = lambda: pathlib.Path({str(rr.SOURCE_ROOT)!r})\n" + ) + + def _probe(self, module): + return ( + "import sys, pathlib\n" + f"sys.path.insert(0, {_src_root()!r})\n" + + self._HOLD_OTHER_BRANCHES + + _WINDOWS_REALPATH + + _DENY_CWD + + f"import {module}\n" + "print('OK')\n" + ) + + def test_the_injected_world_really_reads_the_cwd(self): + """Positive control: prove ``resolve()`` bites before trusting the pins.""" + probe = ( + "import pathlib\n" + _WINDOWS_REALPATH + _DENY_CWD + "try:\n" + " pathlib.Path(__file__ if '__file__' in dir() else '/tmp').resolve()\n" + " print('NO_RAISE')\n" + "except FileNotFoundError:\n" + " print('RAISED')\n" + ) + result = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True) + + assert "RAISED" in result.stdout, f"{result.stdout}{result.stderr}" + + @pytest.mark.parametrize("module", _IMPORT_TIME_LANES) + def test_this_branch_imports_where_resolving_a_path_reads_the_cwd(self, module): + result = subprocess.run([sys.executable, "-c", self._probe(module)], capture_output=True, text=True) + + assert result.returncode == 0, ( + f"{module} died where Path.resolve() reads the cwd — this is the Windows CI red " + f"reproduced on POSIX:\n{result.stderr}" + ) + assert "OK" in result.stdout + + def test_no_import_time_line_in_this_branch_resolves_its_own_file(self): + """The structural half, because the behavioural half only covers what it imports. + + The first sweep of this species keyed on "written at module scope" and + missed ``find_repo_root``'s own default argument — a line inside a + function that four lanes reach during import, and the last crash + standing. So this reads every module the import-time lanes pull in and + fails on the idiom itself, wherever it is written. + """ + offenders = [] + for path in _SOURCES: + text = path.read_text(encoding="utf-8") + prose = _prose_lines(text) + for number, line in enumerate(text.splitlines(), start=1): + stripped = line.strip() + if stripped.startswith("#") or number in prose: + continue + if "Path(__file__).resolve()" not in stripped: + continue + offenders.append(f"{path.relative_to(_APPS.parent)}:{number}") + + assert not offenders, "use repo_root.module_file(__file__) — resolve() reads the cwd on Windows: " + ", ".join( + offenders + ) + + +class TestNothingReadsTheStackAtImportTime: + """``inspect.stack()`` is a cwd read on Windows, and I could not reproduce it here. + + The Windows CI traceback is unambiguous: ``inspect.stack()`` builds a + FrameInfo per frame, ``getsourcefile`` -> ``getmodule`` -> ``os.path.realpath``, + and ``ntpath.realpath`` reads the working directory. It crashed the import + of every handler in this branch. + + ON POSIX IT DOES NOT CRASH FROM A DEAD CWD, and the reason is exact rather + than the cache guess this docstring first carried. ``inspect.getmodule`` + reaches the unprotected ``os.path.realpath`` only past this, at + ``inspect.py:991``:: + + try: + file = getabsfile(object, _filename) + except (TypeError, FileNotFoundError): + return None + + On POSIX ``abspath`` raises inside ``getabsfile`` — and inspect SWALLOWS it, + returning None before the realpath loop is reached. On Windows + ``ntpath.abspath`` uses ``_getfullpathname`` and does not raise for an + absolute path, so execution continues INTO the loop and dies on the + unprotected ``realpath`` there. Same stdlib, opposite outcome, and the + difference is which of two calls fails first. + + Credit where it is due: @spawn measured this on the Windows gate and read it + out of CPython rather than inferring it, correcting a platform claim I had + mailed them a few hours earlier. Verified here against + ``/usr/lib/python3.12/inspect.py`` before adopting it. + + Restoring ``inspect.stack()`` therefore leaves the DEAD-CWD pins GREEN, + which I found by running the mutant rather than by assuming it died. + + THIS DOCSTRING USED TO SAY "on POSIX it cannot crash" AND THAT WAS WRONG — + corrected 2026-08-31 after @spawn reproduced it on Linux and this branch + verified the recipe rather than taking their word. The dead-cwd world is + the wrong injection, not POSIX the wrong platform. See + ``TestTheStackReadIsReproducibleAfterAll`` below for the world that does + reach the crash and for the behavioural pins that now stand beside this one. + + This pin stays anyway, and not as a leftover: it fails on the line the + construct is WRITTEN on, which is where the next copy will appear, and it + convicts a file that never runs in any test. The behavioural pin can only + judge the lanes it imports. + """ + + def test_no_module_reached_during_import_walks_the_stack_with_inspect(self): + offenders = [] + for path in _SOURCES: + text = path.read_text(encoding="utf-8") + prose = _prose_lines(text) + for number, line in enumerate(text.splitlines(), start=1): + stripped = line.strip() + if stripped.startswith("#") or number in prose: + continue + if "inspect.stack()" in stripped: + offenders.append(f"{path.relative_to(_APPS.parent)}:{number}") + + assert not offenders, ( + "inspect.stack() resolves every frame's source file, which reads the working " + "directory on Windows — walk sys._getframe() instead: " + ", ".join(offenders) + ) + + +class TestTheStackReadIsReproducibleAfterAll: + """The behavioural half of the pin above, which this branch said was impossible. + + THE CORRECTION, AND WHOSE IT IS. On 2026-08-31 I wrote "on POSIX it cannot + crash" into the class above and shipped a structural-only pin on the + strength of it. @spawn reproduced the crash on Linux the same night. I was + denying the wrong call: the dead-cwd world denies ``os.getcwd``, and on + POSIX ``posixpath.abspath`` raises FIRST — inside ``inspect.getabsfile()``, + where inspect catches it. The unprotected ``os.path.realpath`` in + ``getmodule``'s loop is never reached, so the world is hostile to a call the + defect does not make. + + THE RECIPE HAS TWO INGREDIENTS, and missing either one produces a green: + + 1. deny ``os.path.realpath`` directly, and + 2. give the top frame a PSEUDO-FILENAME, which means launching the child + with ``python -c`` rather than as a script. + + Ingredient 2 is the one that is easy to drop as noise. ``getsourcefile()`` + opens with a fast path — ``if os.path.exists(filename): return filename`` — + so a frame that came from a real file on disk returns before ``getmodule`` + is ever called and no realpath happens at all. Only ```` and + ```` fall through. Read out of ``inspect.py`` here before adopting + it, on the same standard I asked @spawn to hold me to. + + So the third test below is a NEGATIVE CONTROL FOR THE POSITIVE CONTROL: it + proves the crash needs the launcher, not just the denial. Without it, a + future simplification that runs the probe as a script would leave the + positive control passing while measuring nothing, and a vacuous control and + a cured defect produce the same green. + + WHAT THIS PIN DOES NOT REACH, measured by mutating both sites rather than + reasoned about. ``handlers/__init__.py`` has TWO frame walks, and the import + probe below only executes one of them: + + * ``_find_real_caller`` — load-bearing, taken on every import. Restoring + ``inspect.stack()`` here reddens the first test with CI's own traceback, + ``inspect.py:1009 getmodule -> os.path.realpath``. + * the ``caller_file is None`` diagnostic branch — reached only when NO + real-file frame exists above the guard, and a package import always has + one: ``apps/__init__.py`` does ``from . import handlers``, so it is + always the caller. Restoring ``inspect.stack()`` there leaves every + IMPORT-shaped pin GREEN. + + CORRECTED THE NEXT MORNING, AND THE CORRECTION IS AGAINST MY OWN SENTENCE. + I wrote "only the structural sweep convicts it" and @devpulse relayed that + fleet-wide. It was too strong. Import-shaped pins cannot reach that branch; + a DIRECT CALL to ``_guard_branch_access()`` from a ``python -c`` child + reaches it easily, and @spawn measured that while I was still calling it + unreachable. ``TestTheDiagnosticBranchIsReachableAfterAll`` below is the + behavioural sibling, and the regrown-walk mutant now dies to both + instruments. + + The structural pin stays regardless, and for a reason the correction does + not touch: it needs no subprocess, it names the defect on the line it is + written on, and it convicts files no probe imports. What changed is the + claim, not the pin — "unreachable from the probes I built" was reported as + "unreachable", and those are not the same sentence. + """ + + _WORLD = REALPATH_DENIED_WORLD + + # What the guard used to call. The crash is inside ``inspect.stack()`` + # itself — before any of the walking code runs — so the construct IS the + # reproduction; nothing about the surrounding loop changes the answer. + _PROBE = ( + "import inspect\n" + "try:\n" + " depth = len(inspect.stack())\n" + "except Exception as exc:\n" + " print('RAISED', type(exc).__name__)\n" + "else:\n" + " print('SURVIVED', depth)\n" + ) + + def test_the_shipped_guard_imports_clean_in_the_world_that_kills_the_old_one(self): + probe = f"import sys\nsys.path.insert(0, {_src_root()!r})\n{self._WORLD}import aipass.memory.apps.handlers\nprint('OK')\n" + result = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True) + assert result.returncode == 0, ( + "handlers/__init__.py could not be imported with os.path.realpath denied — " + "this is the Windows CI crash, reproduced.\n" + result.stderr + ) + assert "OK" in result.stdout + + def test_the_construct_the_guard_used_to_call_dies_in_that_same_world(self): + """Positive control: the world is hostile, and hostile to THIS call.""" + result = subprocess.run([sys.executable, "-c", self._WORLD + self._PROBE], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert "RAISED FileNotFoundError" in result.stdout, ( + "The realpath denial did not reach inspect.stack(), so the pin above is measuring nothing: " + result.stdout + ) + + def test_the_crash_needs_a_pseudo_filename_frame_not_just_the_denial(self, tmp_path): + """Negative control FOR the control: same world, real file, no crash. + + If this ever starts reporting RAISED, the recipe has become + one-ingredient and the class docstring is out of date. If the test above + is ever rewritten to run from a file, it will silently join this one. + """ + script = tmp_path / "from_a_real_file.py" + script.write_text(self._WORLD + self._PROBE, encoding="utf-8") + result = subprocess.run([sys.executable, str(script)], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert result.stdout.startswith("SURVIVED"), ( + "A frame from a real file reached the unprotected realpath, which contradicts " + "getsourcefile()'s os.path.exists fast path: " + result.stdout + ) + + +class TestTheDiagnosticBranchIsReachableAfterAll: + """The second correction of the day, and it is against my own sentence. + + I wrote — and @devpulse relayed fleet-wide — that the guard's + ``caller_file is None`` branch can only be watched structurally. The true + sentence is narrower: it is unreachable from IMPORT-shaped pins, because + ``apps/__init__.py`` does ``from . import handlers`` and so always supplies + a real-file frame. It is perfectly reachable by CALLING + ``_guard_branch_access()`` directly from a ``python -c`` child: every frame + is then a string pseudo-name or importlib, both skipped, ``_find_real_caller`` + returns None, and the branch RUNS. + + @spawn measured that; @devpulse confirmed it in their own tree before + relaying it. It is a better sentence than mine and it costs about fifteen + lines, so the excuse for not having the behavioural sibling was never the + price. + + TWO ARMING PROBES, because this world has two ways to be silently inert and + each would leave the pin green while measuring nothing: + + * the realpath denial might not bite at all, and + * the guard might be reaching a DIFFERENT path — if ``_find_real_caller`` + returns a real file, the branch under test never runs and the assertion + below is about code that did not execute. + + The second probe is the one I would not have written a week ago. "The test + passed" and "the test ran the line" are different claims, and only the + second one is worth anything in a world built by injection. + """ + + _WORLD = REALPATH_DENIED_WORLD + _SETUP = "import aipass.memory.apps.handlers as h\n" + + def _child(self, body: str) -> subprocess.CompletedProcess: + probe = f"import sys\nsys.path.insert(0, {_src_root()!r})\n{self._WORLD}{self._SETUP}{body}" + return subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True) + + def test_the_denial_bites_in_this_child(self): + """Arming probe 1: the world is hostile where the defect would live.""" + result = self._child( + "import inspect\n" + "try:\n" + " inspect.stack()\n" + "except FileNotFoundError:\n" + " print('ARMED')\n" + "else:\n" + " print('INERT')\n" + ) + assert result.returncode == 0, result.stderr + assert "ARMED" in result.stdout, "realpath is not denied in this child: " + result.stdout + + def test_the_guard_really_takes_the_caller_is_none_branch_here(self): + """Arming probe 2: the line under test is the line that runs.""" + result = self._child("print('CALLER', h._find_real_caller())\n") + assert result.returncode == 0, result.stderr + assert "CALLER (None, None)" in result.stdout, ( + "a real-file frame reached the walk, so the diagnostic branch is NOT what the " + "pin below exercises: " + result.stdout + ) + + def test_the_diagnostic_branch_returns_instead_of_crashing(self): + result = self._child("h._guard_branch_access()\nprint('RETURNED')\n") + assert result.returncode == 0, ( + "the caller-is-None branch crashed with os.path.realpath denied — this is the " + "second inspect.stack() walk, regrown.\n" + result.stderr + ) + assert "RETURNED" in result.stdout + + +class TestTheTwoWorldsMustNotBeStacked: + """Denying MORE can deny LESS — on POSIX. This class used to say it everywhere. + + THE CI RED THAT REWROTE IT. On 8550ed10 windows-setup was down to exactly + two failures and both were this class:: + + test_getcwd_alone_does_not assert 'DIES' == 'SURVIVES' + test_stacking_them_undoes_the_conviction assert 'DIES' == 'SURVIVES' + + My failure message told the reader to suspect a CPython change. Wrong + suspect: it was the PLATFORM, and the mistake underneath it was that I + measured three verdicts on Linux and wrote them down as facts about + ``inspect``. They are facts about ``posixpath``. + + THE TABLE, and what decides each row. On POSIX, ``abspath`` raises inside + ``inspect.getabsfile()`` — which sits in inspect's own + ``except (TypeError, FileNotFoundError)`` — so a getcwd denial is SWALLOWED + and the unprotected ``os.path.realpath`` in ``getmodule`` is never reached. + On nt, ``abspath`` rides Win32 ``_getfullpathname`` and never touches + ``os.getcwd``, so it sails through that try and arrives at + ``ntpath.realpath``, which reads the cwd unconditionally. + + ================== ======== ======== + world posix nt + ================== ======== ======== + realpath denied DIES DIES + getcwd denied SURVIVES DIES + both denied SURVIVES DIES + ================== ======== ======== + + KEYED ON ``os.name``, not ``sys.platform``, because the question this table + answers is WHICH PATH MODULE the stdlib is using — @ai_mail's rule, and the + right one here: ``sys.platform`` would need a darwin row that behaves + exactly like linux, since darwin runs posixpath. + + WHICH HALVES ARE MEASURED WHERE, stated because a derived row that reads + like a measured one is how a guess becomes a fact: + + * posix rows — measured live, on this host, every run. + * nt rows — measured TWICE. Once on the real Windows runner (8550ed10: + ``realpath denied`` PASSED, and the two reds carry their actual verdict in + the assertion diff, so all three are positive measurements of a value and + not merely proof that something was not SURVIVES). And once here, inside + ``WINDOWS_EMULATED_WORLD``, which patches BOTH halves so every nt row runs + on this machine and is falsifiable on it forever. + + Emulating the denial was never enough; the platform had to be emulated. That + is @prax's correction, and this class is the thing it was aimed at. + """ + + _PROBE = ( + "import inspect\n" + "try:\n" + " inspect.stack()\n" + "except Exception:\n" + " print('DIES')\n" + "else:\n" + " print('SURVIVES')\n" + ) + + # The whole claim, in the shape the reader can check against the docstring. + _EXPECTED = { + ("posix", "realpath"): "DIES", + ("posix", "getcwd"): "SURVIVES", + ("posix", "both"): "SURVIVES", + ("nt", "realpath"): "DIES", + ("nt", "getcwd"): "DIES", + ("nt", "both"): "DIES", + } + + _DENIALS = { + "realpath": REALPATH_DENIED_WORLD, + "getcwd": DEAD_CWD_WORLD, + "both": DEAD_CWD_WORLD + REALPATH_DENIED_WORLD, + } + + def _verdict(self, world: str) -> str: + result = subprocess.run([sys.executable, "-c", world + self._PROBE], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + _EMULATIONS = {"posix": POSIX_EMULATED_WORLD, "nt": WINDOWS_EMULATED_WORLD} + + def test_the_posix_emulation_actually_arms(self): + """Arming probe for the half round 6 forgot to build. + + A posix world must let ``realpath`` through an ABSOLUTE path without + touching the cwd — that is the fact the SURVIVES rows rest on, and the + one an nt host contradicts. + """ + world = ( + POSIX_EMULATED_WORLD + + DEAD_CWD_WORLD + + ( + "import os\n" + "try:\n" + " os.path.realpath('/already/absolute')\n" + " print('ABSOLUTE_REALPATH_IGNORED_THE_CWD')\n" + "except FileNotFoundError:\n" + " print('IT_READ_THE_CWD')\n" + ) + ) + result = subprocess.run([sys.executable, "-c", world], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert "ABSOLUTE_REALPATH_IGNORED_THE_CWD" in result.stdout, ( + "the posix half is not installed, so the rows below are measuring the host: " + result.stdout + ) + + def _world_for(self, platform: str) -> str: + """The emulation every row runs under. Extracted so a pin can convict it. + + Round 6's bug was not in an emulation, it was in the CHOICE: the posix + rows ran with no prefix. That is unfalsifiable on a posix host — bare + and posix agree here by definition — so the mutant that restores it + passes every local run. Naming the choice is what makes it testable. + """ + return self._EMULATIONS[platform] + + def test_every_row_runs_under_an_emulation(self): + """No row inherits the host, and this is the pin the mutant needed. + + Restoring round 6's ``"" if platform == "posix"`` cannot be caught by + any behavioural probe on Linux, because there is nothing to observe: the + bare world IS the posix world here. It can only be caught by asserting + that a world was chosen at all. + """ + for platform in self._EXPECTED_PLATFORMS: + world = self._world_for(platform) + assert world, f"the {platform} rows run bare, so on a {platform}-foreign host they measure the runner" + assert world in self._EMULATIONS.values() + + _EXPECTED_PLATFORMS = ("posix", "nt") + + def test_neither_row_inherits_the_host(self): + """The failure of round 6, pinned: an unemulated row is a row about the runner. + + Reproduced before fixing by standing the nt emulation in for a Windows + host and re-running what used to be the bare posix rows — DIES, exactly + what CI reported under a label that said posix. + """ + for platform, emulation in self._EMULATIONS.items(): + under_the_other_host = self._EMULATIONS["nt" if platform == "posix" else "posix"] + verdict = self._verdict(under_the_other_host + emulation + DEAD_CWD_WORLD) + assert verdict == self._EXPECTED[(platform, "getcwd")], ( + f"the {platform} emulation did not survive being run on the other platform's host — " + "it is reading os.path instead of building its own semantics" + ) + + def test_the_platform_emulation_actually_arms(self): + """Arming probe: without BOTH halves patched, the nt rows are posix rows. + + The abspath half is the one that is easy to leave out, and leaving it + out is silent — the table below would still pass three of its six rows + and the two that changed would look like a platform fact. + """ + world = ( + WINDOWS_EMULATED_WORLD + + DEAD_CWD_WORLD + + ( + "import os\n" + "try:\n" + " os.path.abspath('relative')\n" + " print('ABSPATH_SURVIVED_THE_DENIAL')\n" + "except FileNotFoundError:\n" + " print('ABSPATH_DIED')\n" + ) + ) + result = subprocess.run([sys.executable, "-c", world], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert "ABSPATH_SURVIVED_THE_DENIAL" in result.stdout, ( + "the Win32 abspath half is not installed, so the nt rows below are measuring posix: " + result.stdout + ) + + @pytest.mark.parametrize("denial", ["realpath", "getcwd", "both"]) + @pytest.mark.parametrize("platform", ["posix", "nt"]) + def test_the_table_holds(self, platform, denial): + # BOTH rows are emulated. Round 6 ran the posix rows bare, which reads + # as "posix" only while the host is posix — on the Windows runner they + # measured nt and failed under a label that said otherwise. + prefix = self._world_for(platform) + expected = self._EXPECTED[(platform, denial)] + assert self._verdict(prefix + self._DENIALS[denial]) == expected, ( + f"the {platform} row for a {denial} denial moved. On posix that would be a change in " + "CPython's inspect.getmodule; on nt it would mean the emulation no longer matches the " + "runner. Re-read dead_cwd.py and the CI log before trusting either world." + ) + + def test_no_probe_in_this_branch_stacks_them_outside_the_table(self): + """Structural: only the table above may build the masking world. + + The masking is real on posix and the stacked world is genuinely kinder + there, so a probe that reaches for "as hostile as possible" and + concatenates both gets a world where the defect cannot fire. + """ + offenders = [] + for path in sorted(Path(__file__).parent.rglob("test_*.py")): + if path.name == Path(__file__).name: + continue + text = path.read_text(encoding="utf-8") + if "DEAD_CWD_WORLD + REALPATH_DENIED_WORLD" in text: + offenders.append(path.name) + assert not offenders, "these stack the two denials and measure a kinder world: " + ", ".join(offenders) + + +class TestTheWindowsWorldSurvivesEveryWayPathlibReachesRealpath: + """Python 3.10 held its own copy of ``os.path.realpath``, and my probe missed it. + + CI, 2026-08-31, commit 8550ed10, Python 3.10 ONLY (3.11/3.12/3.13 green):: + + TestResolvingOwnFileIsACwdReadOnWindows::test_the_injected_world_really_reads_the_cwd + AssertionError: NO_RAISE + + That is the arming probe doing its whole job. The world was inert on that + interpreter, so every pin beneath it — the thirty-two import-time lanes — + had been passing on 3.10 while asserting nothing, and no other test could + have said so: a vacuous pin and a cured defect produce the same green. + + THE MECHANISM, READ OUT OF CPython 3.10's ``Lib/pathlib.py`` rather than + inferred, because the diagnosis that reached me was that 3.10's pathlib does + not delegate to ``os.path.realpath`` at all, and that is not what the source + says:: + + 358: realpath = staticmethod(os.path.realpath) # _NormalAccessor + 1077: s = self._accessor.realpath(self, strict=strict) # Path.resolve + + It DOES delegate. It just took its copy when pathlib was first imported. So + a probe that imports pathlib and then rebinds ``os.path.realpath`` is + rebinding a name nothing will read again. 3.11 deleted the accessor and + calls ``os.path.realpath`` at use, which is why exactly one version reddened. + + WHICH MAKES THIS THE SAME DEFECT AS THE GETCWD ONE, one constant over. The + getcwd world has carried the accessor cure since the last 3.10 red, with a + docstring explaining the trap in detail. Its sibling did not, because the + sibling lived in this test file instead of next to that docstring. A cure + that does not travel to the constant twenty lines away is the shape this + branch has now shipped twice; both worlds live in ``dead_cwd.py`` now. + + NOT FALSIFIABLE BY RUNNING 3.10 — there is no 3.10 interpreter on this + machine. So the accessor is BUILT here instead, the way ``ACCESSOR_SHAPE`` + already builds the getcwd one, and the pins below run on any interpreter. + That is a stand-in for a version, and it expires the moment a real 3.10 run + disagrees with it — which is the honest form for a row a local host cannot + reach. + """ + + # 3.10's shape, reduced to the two lines that matter: the accessor takes + # its copy of os.path.realpath at class-definition time (pathlib.py:358), + # and Path.resolve reaches it through an INSTANCE (pathlib.py:1077). + # + # THE CAPTURED FUNCTION IS A SENTINEL, NOT THE REAL ONE, and that is the + # round-7 correction. This class first let the accessor capture the live + # ``os.path.realpath`` and then used a getcwd denial to ask whether a later + # patch had reached it. On POSIX that works: the captured ``posixpath`` + # function ignores the cwd for an absolute path, so RAISED could only mean + # the patch had landed. On nt it is ambiguous — ``ntpath.realpath`` reads + # ``os.getcwd()`` UNCONDITIONALLY (this branch's own round-4 finding), so + # the ORIGINAL raises too and RAISED no longer discriminates. Both accessor + # pins failed on the Windows runner for that reason, and neither failure was + # about the accessor. + # + # A sentinel that touches no filesystem and no cwd removes the platform from + # the measurement entirely: whatever raises or returns, it is the patch's + # doing. An instrument should not import behaviour it is not testing. + _PRE_CAPTURED_ACCESSOR = ( + "import os, pathlib\n" + "def _original_realpath(p, *a, **k):\n" + " return p\n" + "os.path.realpath = _original_realpath\n" + "class _NormalAccessor:\n" + " realpath = staticmethod(os.path.realpath)\n" + "pathlib._NormalAccessor = _NormalAccessor\n" + "pathlib._the_accessor = _NormalAccessor()\n" + ) + + # A literal no platform can complete with a drive. The round-7 red printed + # 'NO_RAISE D:\\tmp' against a hardcoded '/tmp', because a POSIX-absolute + # literal is DRIVE-RELATIVE on nt. + # + # PUBLISHED AS AN EQUIVALENT MUTANT GIVEN THE SENTINEL, because it is: with + # the captured original returning its argument untouched, nothing resolves + # the literal on any platform, so restoring '/tmp' passes everywhere. The + # sentinel fix SUBSUMED this one — two symptoms, one cause. It stays as the + # cheaper of the two guards, and it is named here so nobody reports it as a + # second cure or deletes it as decoration. + _PROBE_PATH = "SENTINEL_PATH_NOT_A_REAL_FILE" + + # Asked the way 3.10 asks: through an instance, which is what makes the + # staticmethod load-bearing rather than decoration. + _ASK_THE_ACCESSOR = ( + "import pathlib\n" + "try:\n" + f" got = pathlib._the_accessor.realpath({_PROBE_PATH!r})\n" + " print('NO_RAISE', got)\n" + "except FileNotFoundError:\n" + " print('RAISED')\n" + ) + + _BARE_PATCH = ( + "import os\n" + "_before = os.path.realpath\n" + "def _reads_the_cwd(p, *a, **k):\n" + " os.getcwd()\n" + " return _before(p, *a, **k)\n" + "os.path.realpath = _reads_the_cwd\n" + ) + + def _verdict(self, world: str) -> str: + result = subprocess.run([sys.executable, "-c", world], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + def test_the_bare_patch_never_reaches_a_pre_captured_accessor(self): + """The 3.10 defect, reproduced on this interpreter. Red before the cure.""" + world = self._PRE_CAPTURED_ACCESSOR + self._BARE_PATCH + DEAD_CWD_WORLD + self._ASK_THE_ACCESSOR + assert self._verdict(world).startswith("NO_RAISE"), ( + "a bare os.path.realpath patch now reaches a pre-captured accessor, which would " + "mean the 3.10 red had some other cause — re-read the CI log before trusting this file" + ) + + def test_the_shipped_world_does_reach_it(self): + """The cure: patch the accessor when one exists, exactly as the getcwd world does.""" + world = self._PRE_CAPTURED_ACCESSOR + WINDOWS_REALPATH_WORLD + DEAD_CWD_WORLD + self._ASK_THE_ACCESSOR + assert self._verdict(world) == "RAISED", ( + "WINDOWS_REALPATH_WORLD did not arm through the accessor — this is the Python 3.10 " + "NO_RAISE from CI, reproduced" + ) + + def test_it_still_arms_where_there_is_no_accessor_at_all(self): + """3.11+ has no accessor. The cure must not depend on finding one.""" + world = ( + WINDOWS_REALPATH_WORLD + + DEAD_CWD_WORLD + + ( + "import pathlib\n" + "try:\n" + " pathlib.Path('/tmp').resolve()\n" + " print('NO_RAISE')\n" + "except FileNotFoundError:\n" + " print('RAISED')\n" + ) + ) + assert self._verdict(world) == "RAISED" + + @pytest.mark.parametrize("host", ["posix", "nt"]) + def test_these_verdicts_do_not_move_when_the_host_changes(self, host): + """The round-7 red, pinned: an instrument must not import the platform. + + Both pins above failed on the Windows runner, and neither failure was + about the accessor. The old probe let the accessor capture the LIVE + ``os.path.realpath`` and then asked "did a later patch reach it?" by + denying the cwd — a question only ``posixpath`` answers cleanly, because + ``ntpath.realpath`` reads ``os.getcwd()`` unconditionally and so the + ORIGINAL raises too. + + Run here under both emulated platforms. If the sentinel is ever replaced + by the real function, the nt case goes red on this machine rather than + three days later on a runner. + """ + under = POSIX_EMULATED_WORLD if host == "posix" else WINDOWS_EMULATED_WORLD + bare = under + self._PRE_CAPTURED_ACCESSOR + self._BARE_PATCH + DEAD_CWD_WORLD + self._ASK_THE_ACCESSOR + cured = under + self._PRE_CAPTURED_ACCESSOR + WINDOWS_REALPATH_WORLD + DEAD_CWD_WORLD + self._ASK_THE_ACCESSOR + + assert self._verdict(bare).startswith("NO_RAISE"), f"host={host}: the bare patch verdict is host-dependent" + assert self._verdict(cured) == "RAISED", f"host={host}: the cured world's verdict is host-dependent" + + def test_the_accessor_still_answers_CORRECTLY_when_the_cwd_is_fine(self): + """A world that raises for the right reason can still return the wrong path. + + ``staticmethod`` is what stops this. Through an instance a plain + function BINDS, so the accessor arrives as the first positional and the + real path slides into ``*a`` — the denial still fires, so every + raise-shaped pin above stays green, and the world silently starts + resolving the accessor object instead of the file. Dropping the + ``staticmethod`` survived the whole suite until this test existed, which + is why it is here rather than in a comment saying "obviously needed". + """ + world = self._PRE_CAPTURED_ACCESSOR + WINDOWS_REALPATH_WORLD + self._ASK_THE_ACCESSOR + assert self._verdict(world) == f"NO_RAISE {self._PROBE_PATH}", ( + "the patched accessor did not return the path it was handed — a bound plain " + "function eats the argument. It must be a staticmethod." + ) diff --git a/src/aipass/memory/tests/test_residency_scope.py b/src/aipass/memory/tests/test_residency_scope.py index f4cfe486b..1fee6ad29 100644 --- a/src/aipass/memory/tests/test_residency_scope.py +++ b/src/aipass/memory/tests/test_residency_scope.py @@ -51,11 +51,16 @@ """ import json +import logging +import subprocess +import sys +from collections import Counter from pathlib import Path import pytest from aipass.memory.apps.handlers.monitor import registry_scope as rs +from aipass.memory.tests.dead_cwd import DEAD_CWD_WORLD def _write(path: Path, data: dict) -> None: @@ -170,8 +175,14 @@ def test_discovery_does_not_descend_past_one_level(self, fleet): ``projects/live/.backup/LIVE_REGISTRY.json`` is excluded twice over, by depth and by dot-directory. Asserted on depth alone here so the two rules cannot be mistaken for one. + + The non-empty guard is the whole test. A depth rule asserted inside a + loop is proven by the paths that ENTER it, so a discovery returning + nothing passes this green while checking nothing at all. """ - for path in rs.resident_registry_paths(fleet): + found = rs.resident_registry_paths(fleet) + assert found, "discovery returned nothing, so the depth rule was never exercised" + for path in found: assert len(path.relative_to(fleet / "projects").parts) == 2, f"discovered below depth one: {path}" def test_a_missing_projects_directory_is_not_an_error(self, tmp_path): @@ -361,17 +372,44 @@ def test_discovery_names_exactly_the_four_resident_projects(self, live_residents assert found == {"baud", "earmark", "finch", "aipass-site"} def test_the_live_fleet_is_eighteen_core_and_four_residents(self, live_residents): + """Counted BY TIER now, not by total — and the change is the point. + + This asserted a flat 22 until the external tier landed, and it went red + on the first run afterwards because the fleet legitimately grew. Pinning + a total made the guard a tripwire for its own feature: the two numbers it + was written to protect (18 core, 4 residents) were never what it read. + + So it counts what it means, and the external tier is asserted against + the DECLARATION rather than a number — this machine happens to declare + four roots today and that is Patrick's to change without breaking a test + in my branch. + """ branches = rs.fleet_branches(live_residents) - residents = [item for item in branches if f"/{rs.RESIDENT_PROJECTS_DIR}/" in str(item["path"])] - assert (len(branches), len(residents)) == (22, 4), [item["name"] for item in branches] + tiers = Counter(item["residency"] for item in branches) + assert (tiers[rs.RESIDENCY_CORE], tiers[rs.RESIDENCY_RESIDENT]) == (18, 4), [item["name"] for item in branches] + externals = [item for item in branches if item["residency"] == rs.RESIDENCY_EXTERNAL] + assert len(externals) == tiers[rs.RESIDENCY_EXTERNAL] + assert bool(externals) == bool(rs.declared_roots(live_residents)), ( + "external citizens appeared without a declared root, or a declared root produced none" + ) def test_every_live_resident_declares_itself_one(self, live_residents): - """The declaration is the classifier now — so it had better be there.""" + """The declaration is the classifier now — so it had better be there. + + Counted, not just iterated: two nested loops over live discovery mean + two chances to check nothing. The fixture already skips when the + registries are missing, so reaching here with a count of zero is a + different failure — discovery found the files and read no branches out + of them — and it must not read as agreement. + """ + checked = 0 for registry_path in rs.resident_registry_paths(live_residents): for item in rs.read_registry_branches(registry_path): + checked += 1 assert rs.declared_residency(item["path"]) == rs.RESIDENCY_RESIDENT, ( f"{item['name']} is in fleet scope but its passport does not declare it" ) + assert checked, "no live resident branch was read, so nothing was proven about any declaration" def test_the_archived_projects_are_still_on_disk_and_still_out(self, live_fleet): """The exclusion is only proven where the thing being excluded exists.""" @@ -382,3 +420,429 @@ def test_the_archived_projects_are_still_on_disk_and_still_out(self, live_fleet) assert stale, "the archive carries no registries, so it cannot prove the exclusion" names = {item["name"] for item in rs.fleet_branches(live_fleet)} assert "marketstand" not in names and "speakeasy" not in names + + +class TestTheRecordCarriesTheAddress: + """A fleet record without an email is unusable by every email-addressed lane. + + Asked for by @daemon (dispatch 16fbf1c0) and ruled in their favour: the row + is already read and the field already thrown away, so resolving it anywhere + else would mean a second reader of the same registry rows — the exact defect + this module exists to end. @ai_mail is email-addressed too, so shipping the + record without it would strand both of the lanes 2.0.0 was meant to converge. + + Pass-through, never derived. ``name`` may come from the directory, and an + address guessed from a directory name is a wrong answer that looks right. + """ + + def test_every_record_carries_an_email_key(self, fleet): + records = rs.fleet_branches(fleet) + assert records, "nothing was discovered, so the record shape was never exercised" + addressless = [r["name"] for r in records if "email" not in r] + assert not addressless, f"records with no email key: {addressless}" + + def test_the_email_is_the_registrys_verbatim_not_derived_from_the_name(self, tmp_path): + """A row whose address looks nothing like its name or its directory. + + ``@daemon`` asked for the field; this pins WHERE it comes from. Both + plausible derivations — lowercase the name, or read the directory — + produce a different string here, so either one fails this outright. + """ + _write( + tmp_path / "AIPASS_REGISTRY.json", + _registry({"name": "ALPHA", "path": "src/aipass/alpha", "email": "@not-the-name", "status": "active"}), + ) + _write(tmp_path / "src/aipass/alpha/.trinity/passport.json", _passport("core")) + (record,) = rs.fleet_branches(tmp_path) + assert record["email"] == "@not-the-name" + + def test_a_row_with_no_email_keeps_the_branch_and_reports_none(self, tmp_path): + """Addressless is a condition to hand on, not a reason to drop a citizen. + + Path-based lanes (trinity_push, rollover) never touch the address, so + dropping the branch here would break them to protect a caller that has + not asked yet. The record reports ``None`` and the caller refuses on + its own terms. Measured on the live fleet at 22 of 22 rows carrying an + address, so this is the shape of a future defect, not today's. + """ + _write( + tmp_path / "AIPASS_REGISTRY.json", + _registry({"name": "ALPHA", "path": "src/aipass/alpha", "status": "active"}), + ) + _write(tmp_path / "src/aipass/alpha/.trinity/passport.json", _passport("core")) + (record,) = rs.fleet_branches(tmp_path) + assert record["name"] == "alpha" + assert record["email"] is None + + def test_the_live_fleet_is_addressable_end_to_end(self, live_fleet): + """The guard that would have caught this before @daemon had to ask.""" + records = rs.fleet_branches(live_fleet) + assert records, "live discovery returned nothing -- this proved nothing" + unaddressed = [r["name"] for r in records if not r.get("email")] + assert not unaddressed, f"live citizens with no address in their registry row: {unaddressed}" + + def test_each_record_gets_its_own_rows_address_not_a_neighbours(self, tmp_path): + """Three rows, three addresses, none guessable from its own name. + + Found by mutation: a version handing every record the FIRST row's email + survived every other test in this class, because the single-row + registries above cannot see a cross-row mixup and the live guard only + asks whether an address is present. For an email-addressed scheduler + that mutant is the worst available failure — every job would wake a real + citizen, just never the right one — so it is pinned by pairing, not by + presence. + """ + rows = [ + {"name": "ALPHA", "path": "src/aipass/alpha", "email": "@third", "status": "active"}, + {"name": "BETA", "path": "src/aipass/beta", "email": "@first", "status": "active"}, + {"name": "GAMMA", "path": "src/aipass/gamma", "email": "@second", "status": "active"}, + ] + _write(tmp_path / "AIPASS_REGISTRY.json", _registry(*rows)) + for row in rows: + _write(tmp_path / row["path"] / ".trinity" / "passport.json", _passport("core")) + found = {record["name"]: record["email"] for record in rs.fleet_branches(tmp_path)} + assert found == {"alpha": "@third", "beta": "@first", "gamma": "@second"} + + +class TestMalformedJsonDeclaresNothingAndNeverRaises: + """A file that parses as JSON but is the wrong SHAPE must refuse, not crash. + + Reported by @daemon (dispatch 5031a591) against declared_residency, and the + provenance is the point: their deleted reader had an explicit isinstance + guard that mine never had. Removing the duplicate removed the stricter of + two implementations, and nobody knew which one was stricter. + + Why it is fleet-wide rather than branch-local: declared_residency is called + once per core citizen by fleet_branches and once per candidate by + _accepted_residents, and read_registry_branches is called for every registry + in scope. An uncaught raise in either does not refuse ONE branch -- it takes + out rollover, lint, health, trinity_push and @daemon's scheduler together, + with a traceback naming this module rather than the file that is wrong. + + The module docstring is the specification being broken: an unreadable + passport "declares NOTHING and does not raise". A non-dict root is + unreadable in every sense that matters. + + Every root is pinned separately. @daemon's own suite pinned only "[]", and a + fix tested against a list alone would have let a string root through -- so + the parametrisation is the test, not decoration. + """ + + @pytest.mark.parametrize( + "label, document", + [ + ("list", "[]"), + ("string", '"resident"'), + ("number", "7"), + ("null", "null"), + ("bool", "true"), + ], + ) + def test_a_non_dict_passport_root_declares_nothing(self, tmp_path, label, document): + """Five roots, not the three reported: null and bool raise too.""" + branch = tmp_path / f"branch_{label}" + (branch / ".trinity").mkdir(parents=True) + (branch / ".trinity" / "passport.json").write_text(document, encoding="utf-8") + assert rs.declared_residency(branch) is None + + @pytest.mark.parametrize( + "label, document", + [ + ("string", '{"citizenship": "core"}'), + ("list", '{"citizenship": []}'), + ("number", '{"citizenship": 1}'), + ], + ) + def test_a_non_dict_citizenship_block_declares_nothing(self, tmp_path, label, document): + """The second `.get` on the same line had the same defect. + + `data.get("citizenship", {}).get("residency")` guards an ABSENT block + with its default and a PRESENT-but-wrong one not at all, so fixing only + the root would have left the identical crash one key deeper. Not in + @daemon's report -- found by reading the line that was reported. + """ + branch = tmp_path / f"branch_cz_{label}" + (branch / ".trinity").mkdir(parents=True) + (branch / ".trinity" / "passport.json").write_text(document, encoding="utf-8") + assert rs.declared_residency(branch) is None + + @pytest.mark.parametrize( + "label, document", + [ + ("list root", "[]"), + ("string root", '"whatever"'), + ("number root", "7"), + ("null root", "null"), + ("branches is a mapping", '{"branches": {"alpha": 1}}'), + ("branches is a string", '{"branches": "alpha"}'), + # The three NON-ITERABLE values. Found by mutation: with only the + # mapping and string cases above, deleting the `branches` list guard + # SURVIVED, because iterating a dict or a string still yields items + # and the row guard below catches every one of them. A number, a + # bool or an explicit null raises TypeError at the `for` itself, + # where no row guard can ever reach. `null` is the live shape of the + # three: `data.get("branches", [])` returns None when the key is + # PRESENT and null, so the default never fires. + ("branches is a number", '{"branches": 7}'), + ("branches is a bool", '{"branches": true}'), + ("branches is null", '{"branches": null}'), + ("a branch is a string", '{"branches": ["alpha"]}'), + ("a branch is a list", '{"branches": [[]]}'), + ], + ) + def test_a_malformed_registry_yields_no_branches(self, tmp_path, label, document): + """The registry reader has the same defect and nobody had tested it. + + Worse than the passport case, because a registry is the SEALED ANCHOR: + the passport is agent-written and expected to be wrong sometimes, while + every lane trusts the registry to be the thing a passport cannot forge. + A registry that crashes the reader takes the anchor with it. + """ + registry = tmp_path / f"{label.replace(' ', '_')}_REGISTRY.json" + registry.write_text(document, encoding="utf-8") + assert rs.read_registry_branches(registry) == [] + + def test_a_malformed_row_is_skipped_and_the_good_rows_survive(self, tmp_path): + """One bad row must cost one row, not the whole registry. + + This is the difference between refusing and crashing, stated as a test: + a fleet where one typo hides every other citizen is not failing honestly, + it is failing loudly in the wrong place. + """ + registry = tmp_path / "MIXED_REGISTRY.json" + registry.write_text( + json.dumps( + { + "branches": [ + "not-a-dict-at-all", + {"name": "GOOD", "path": "src/good", "email": "@good", "status": "active"}, + {"name": "BADPATH", "path": 123, "email": "@badpath", "status": "active"}, + {"name": "INACTIVE", "path": "src/inactive", "status": "retired"}, + ] + } + ), + encoding="utf-8", + ) + found = rs.read_registry_branches(registry, name_from="name") + assert [record["name"] for record in found] == ["GOOD"] + + def test_a_non_string_name_or_address_never_reaches_the_record(self, tmp_path): + """Wrong TYPE is a wrong answer, not a crash -- and it is still wrong. + + Neither of these raises, so they would have survived the guard above. + An int name breaks any caller that formats it, and a non-string address + is unmailable; @daemon consumes both. Name falls back to the directory, + which is a real answer; an address has no honest fallback, so it is None + and the caller refuses on its own terms. + """ + registry = tmp_path / "TYPES_REGISTRY.json" + registry.write_text( + json.dumps({"branches": [{"name": 42, "path": "src/alpha", "email": ["@a"], "status": "active"}]}), + encoding="utf-8", + ) + (record,) = rs.read_registry_branches(registry, name_from="name") + assert record["name"] == "alpha" + assert record["email"] is None + + +# =========================================================================== +# THE MODULE-LEVEL CWD FALLBACK (2026-08-31) +# =========================================================================== +# +# @drone, routed by @devpulse with an isolated repro: `find_repo_root` ends +# `return Path.cwd()` when the walk up from `__file__` finds no +# AIPASS_REGISTRY.json, and `REPO_ROOT = find_repo_root()` runs at MODULE +# level. A clean checkout has no registry — it is gitignored and machine-local +# — so a bare CI runner takes that fallback on every import, and a process +# whose working directory has been deleted raises FileNotFoundError while +# merely IMPORTING this module. +# +# It took down every import of drone on CI — router, `drone rm`, `drone +# systems` — because their registry_handler imported the gateway at module +# level. They contained their half honestly (the import moved inside the guard +# that already promised the gateway could not take routing down) and reported +# the line as mine rather than patching my tree. It is mine, and it was latent +# for every other consumer. +# +# TWO DEFECTS IN ONE LINE. The crash is the loud one. The quiet one is that +# `Path.cwd()` is a GUESS: the directory a process happened to start in has +# nothing to do with where this source file lives, so on a registry-less tree +# every fleet lane would silently resolve against whatever the caller's shell +# was pointing at. That is the fallback species Patrick outlawed — the same +# ruling as `_first_registry_in`, "a fallback wearing a determinism costume". +# +# THE ANSWER: the root is derived from THIS FILE's own location, never from the +# process. `src/` is the layout's own marker, so a registry-less checkout +# resolves to the checkout — which is the true answer there, not a guess — and +# the absence is said out loud at WARNING rather than passed over. + + +class TestRepoRootNeverReadsTheProcessDirectory: + """A registry-less world must resolve, deterministically, without cwd. + + Runs on every platform, Windows included — see ``_PROBE`` for why the world + is built by denying ``getcwd`` rather than by deleting a directory. + """ + + # The condition is "os.getcwd() raises", NOT "a directory was deleted". + # Deleting the working directory is one cause of it and the one @drone's + # repro used; it is also IMPOSSIBLE on Windows, which locks the directory a + # process stands in, so the rmdir raises PermissionError before the pin's + # own claim is reached. Denying getcwd produces the same condition from the + # same call site on every platform. test_repo_root.py proves the two worlds + # agree on POSIX, which is what licenses using this one where the other + # cannot be built. + # ONE definition of the world, in tests/dead_cwd.py. This file used to carry + # its own spelling of the denial and it drifted in two ways at once: a + # zero-argument lambda, which 3.10's pathlib accessor calls with one + # argument, and the denial installed AFTER `import pathlib`, which on 3.10 + # means the accessor already captured the real getcwd and these pins were + # asserting nothing at all. CI could only see the first. The world goes + # first here now, and it is imported rather than retyped. + _PROBE = "{world}import sys, pathlib\nsys.path.insert(0, {src!r})\n{body}\n" + + @classmethod + def _in_a_dead_cwd(cls, body): + """Run *body* in a subprocess that cannot read its working directory. + + A subprocess because the condition is process-wide and unfixable from + inside: once cwd cannot be read, every `Path.cwd()` in the interpreter + raises, including pytest's own. Imposing it on the test runner would + take the suite with it. + """ + src = str(Path(rs.__file__).resolve().parents[6]) + return subprocess.run( + [sys.executable, "-c", cls._PROBE.format(world=DEAD_CWD_WORLD, src=src, body=body)], + capture_output=True, + text=True, + ) + + def test_find_repo_root_survives_a_deleted_working_directory(self, tmp_path): + """@drone's isolated repro, exactly: no registry above, no cwd beneath.""" + bare = tmp_path / "bare" + bare.mkdir() + result = self._in_a_dead_cwd( + "from aipass.memory.apps.handlers.monitor import registry_scope as rs\n" + f"print('OK', rs.find_repo_root(pathlib.Path({str(bare)!r})))" + ) + assert result.returncode == 0, ( + "find_repo_root died in a dead cwd. " + + ( + "THE CRASH IS IN @prax, not here — cured 2026-08-31, so this is a regression " + "in their tree. Route it there.\n" + if "/aipass/prax/" in result.stderr + else "" + ) + + result.stderr + ) + assert "OK" in result.stdout, result.stdout + + def test_importing_the_module_survives_a_deleted_working_directory(self): + """The CI chain: REPO_ROOT is resolved at import, so import is the crash site. + + Honest about its own reach: on a machine that HAS a registry above this + file the walk succeeds and the fallback is never taken, so this pin + cannot go red here. It is the CI shape, kept because that is the + environment the defect lives in and a test that only runs where the bug + cannot happen is the one nobody writes until after the outage. + + AND IT WORKED — twice. On CI it went red on ``detector.py``, the same + defect one file over, which is what put the whole ten-copy sweep in + ``handlers/repo_root.py``. Then, with all ten cured, it went red again + on @prax: every handler in the fleet calls ``get_system_logger()`` at + module level and prax's ``config/load.py`` carries the same species. So + @prax swept eight sites of the same species and their fix landed the + same night, verified here: 11/11 memory modules import clean in a dead + cwd against their tree with no substitution. So this is a HARD failure + again — an xfail that outlives its blocker stops reporting regressions — + and what survives is the attribution in the message. + """ + result = self._in_a_dead_cwd( + "from aipass.memory.apps.handlers.monitor import registry_scope as rs\nprint('OK', rs.REPO_ROOT)" + ) + assert result.returncode == 0, ( + "importing registry_scope died in a dead cwd. " + + ( + "THE CRASH IS IN @prax, not here — cured 2026-08-31, so this is a regression " + "in their tree. Route it there.\n" + if "/aipass/prax/" in result.stderr + else "" + ) + + result.stderr + ) + + def test_a_registryless_world_resolves_to_the_source_tree_not_the_caller(self, tmp_path, monkeypatch): + """The QUIET defect: cwd is a guess about where the code lives. + + Stand in a directory that is not the repo and ask about a tree with no + registry. The old answer was "wherever you happen to be standing". + """ + bare = tmp_path / "bare" + bare.mkdir() + monkeypatch.chdir(tmp_path) + + resolved = rs.find_repo_root(bare) + + assert resolved != tmp_path, "the caller's directory is not a repo root" + assert resolved == Path(rs.__file__).resolve().parents[6], ( + "a registry-less world must resolve from this file's own location" + ) + + def test_the_missing_registry_is_said_out_loud(self, tmp_path, caplog): + """A fallback nobody can see is the species this whole sweep is about.""" + bare = tmp_path / "bare" + bare.mkdir() + + with caplog.at_level(logging.WARNING): + rs.find_repo_root(bare) + + assert rs.CORE_REGISTRY in caplog.text, "the walk failed silently" + + def test_a_real_registry_still_wins(self, tmp_path): + """The fallback must not shadow an answer the walk can actually find.""" + root = tmp_path / "repo" + (root / "src" / "aipass").mkdir(parents=True) + (root / rs.CORE_REGISTRY).write_text("{}", encoding="utf-8") + + assert rs.find_repo_root(root / "src" / "aipass") == root + + +class TestTheResidentWalkReadsNamesNotSpellings: + """The same widening @drone found on Windows, on the other of my two globs. + + ``*/*_REGISTRY.json`` is a rule about names; a glob on a case-insensitive + filesystem is wider than the rule it spells. The consequence here differs + from the external walk's and is worse in one way: this one does not refuse, + it ADMITS. A project carrying a lowercase registry beside its real one gets + both read, and a branch nobody declared active in the file that counts + becomes a resident on Windows and not on Linux. + + See ``case_insensitive_filesystem`` in conftest for why this is injected + rather than skipped off-platform. + """ + + def test_the_injected_world_really_widens(self, fleet, case_insensitive_filesystem): + """Positive control: a blinded emulation reports green exactly like a cure.""" + _write(fleet / "projects/live/flow_json_registry.json", _registry(_branch("mirage", "src/mirage/mirage"))) + + matched = sorted(path.name for path in (fleet / "projects").glob(rs.RESIDENT_REGISTRY_GLOB)) + + assert "flow_json_registry.json" in matched and "LIVE_REGISTRY.json" in matched + + def test_a_lowercase_registry_is_not_discovered(self, fleet, case_insensitive_filesystem): + _write(fleet / "projects/live/flow_json_registry.json", _registry(_branch("mirage", "src/mirage/mirage"))) + + found = [path.name for path in rs.resident_registry_paths(fleet)] + + assert "flow_json_registry.json" not in found + assert "LIVE_REGISTRY.json" in found + + def test_a_lowercase_registry_never_mints_a_resident(self, fleet, case_insensitive_filesystem): + """The end-to-end shape: presence in the wrong file must not reach the fleet.""" + _write(fleet / "projects/live/flow_json_registry.json", _registry(_branch("mirage", "src/mirage/mirage"))) + _write(fleet / "projects/live/src/mirage/mirage/.trinity/passport.json", _passport("resident")) + + names = {Path(path).name for path in rs.accepted_resident_paths(fleet)} + + assert "mirage" not in names + assert "live" in names, "the real resident was lost while excluding the impostor" diff --git a/src/aipass/memory/tests/test_rollover.py b/src/aipass/memory/tests/test_rollover.py index 0fe617e62..248a7f12b 100644 --- a/src/aipass/memory/tests/test_rollover.py +++ b/src/aipass/memory/tests/test_rollover.py @@ -65,10 +65,20 @@ def _prepare_rollover_mocks(monkeypatch): mock_memory_watcher.check_and_rollover = MagicMock() monitor_pkg = MagicMock() + + # (test_import_isolation.py) — a bare MagicMock has none, and any lazy + + # submodule import under it then dies with "is not a package". + monitor_pkg.detector = mock_detector monitor_pkg.memory_watcher = mock_memory_watcher rollover_pkg = MagicMock() + + # (test_import_isolation.py) — a bare MagicMock has none, and any lazy + + # submodule import under it then dies with "is not a package". + rollover_pkg.orchestrator = mock_orchestrator # help_flags and json_flag are pure argument inspection with no @@ -91,6 +101,11 @@ def _prepare_rollover_mocks(monkeypatch): cli_pkg.json_flag = real_json_flag handlers_pkg = MagicMock() + + # (test_import_isolation.py) — a bare MagicMock has none, and any lazy + + # submodule import under it then dies with "is not a package". + handlers_pkg.monitor = monitor_pkg handlers_pkg.rollover = rollover_pkg handlers_pkg.cli = cli_pkg diff --git a/src/aipass/memory/tests/test_rollover_pipeline.py b/src/aipass/memory/tests/test_rollover_pipeline.py index cf09fb7f4..56d083dbe 100644 --- a/src/aipass/memory/tests/test_rollover_pipeline.py +++ b/src/aipass/memory/tests/test_rollover_pipeline.py @@ -25,10 +25,14 @@ """ import json +import logging import subprocess +import types import sys from datetime import datetime from pathlib import Path +import pytest +from types import ModuleType from unittest.mock import MagicMock, patch @@ -47,12 +51,27 @@ def _import_orchestrator(monkeypatch): mock_line_counter = MagicMock() monitor_pkg = MagicMock() + + # (test_import_isolation.py) — a bare MagicMock has none, and any lazy + + # submodule import under it then dies with "is not a package". + monitor_pkg.detector = mock_detector rollover_pkg = MagicMock() + + # (test_import_isolation.py) — a bare MagicMock has none, and any lazy + + # submodule import under it then dies with "is not a package". + rollover_pkg.extractor = mock_extractor tracking_pkg = MagicMock() + + # (test_import_isolation.py) — a bare MagicMock has none, and any lazy + + # submodule import under it then dies with "is not a package". + tracking_pkg.line_counter = mock_line_counter monkeypatch.setitem(sys.modules, "aipass.memory.apps.handlers.monitor", monitor_pkg) @@ -159,7 +178,17 @@ def _import_rollover_module(monkeypatch): rollover_pkg = MagicMock() rollover_pkg.orchestrator = mock_orchestrator - handlers_pkg = MagicMock() + # A real ModuleType, not a MagicMock: the stand-in has to survive being + # treated as a package by the import machinery, and a MagicMock raises + # AttributeError for __spec__ the moment importlib asks. + handlers_pkg = ModuleType("aipass.memory.apps.handlers") + # A package stand-in must carry a __path__ that reaches the REAL package + # (test_import_isolation.py) — a bare MagicMock has none, and any lazy + # submodule import under it then dies with "is not a package". modules/ + # rollover.py imports handlers.cli.help_flags, which this harness does not + # stand in for, so without this line the whole file only passes when some + # earlier test file happens to have imported handlers for real. + handlers_pkg.__path__ = [str(Path(__file__).resolve().parents[1] / "apps" / "handlers")] handlers_pkg.monitor = monitor_pkg handlers_pkg.rollover = rollover_pkg @@ -602,7 +631,11 @@ def test_v2_extraction_enriches_entries(self, monkeypatch, tmp_path): file_path.write_text(json.dumps(data, indent=2), encoding="utf-8") mocks["memory_files"].read_memory_file_data.return_value = data - mocks["memory_files"].write_memory_file_simple.return_value = None + # True, not None: the real writer returns a success boolean, and a + # stand-in more generous than the thing it stands in for is how a guard + # stops being observable. `None` read as "don't care" only while the + # return value was being discarded — which was the defect. + mocks["memory_files"].write_memory_file_simple.return_value = True result = ext.extract_with_metadata(file_path) @@ -1028,11 +1061,18 @@ def test_is_misplaced_entry_date_guard_matrix(self, monkeypatch): above_head = {"number": 99, "date": "2020-01-01"} numberless = {"date": today} - # Date guard on (regular lanes) — unchanged behaviour + # Date guard on (regular lanes) — unchanged behaviour. An unknown + # head_date reads as "not today", the conservative default. assert ext._is_misplaced_entry(fresh_today, 10) is True assert ext._is_misplaced_entry(above_head, 10) is True assert ext._is_misplaced_entry(numberless, 10) is True + # ...and a head that is ITSELF dated today makes "dated today" stop + # separating anything, so ordering decides for entries below it. + assert ext._is_misplaced_entry(fresh_today, 10, head_date=today) is False + assert ext._is_misplaced_entry(above_head, 10, head_date=today) is True + assert ext._is_misplaced_entry(numberless, 10, head_date=today) is True + # Date guard off (snapshot lane) — ordering decides, date does not assert ext._is_misplaced_entry(fresh_today, 10, date_guard=False) is False assert ext._is_misplaced_entry(above_head, 10, date_guard=False) is True @@ -1040,6 +1080,200 @@ def test_is_misplaced_entry_date_guard_matrix(self, monkeypatch): assert ext._is_misplaced_entry(numberless, 10, date_guard=False) is True +class TestARefusedWriteMustNotReadAsASuccessfulRollover: + """Found live 2026-08-30 against @seedgo's real memory file. + + `write_memory_file` enforces the trinity entry caps and REFUSES the whole + file when any entry is over — correct behaviour. `_write_memory_file` + called it and threw the boolean away, so the refusal reached nobody: + + 1. rollover extracts 12 key_learnings (in memory) + 2. the write-back is refused, because an UNRELATED array — sessions[0], + a 343-char summary against a 300 cap — puts the document over + 3. the discarded False means no exception, so the caller's except cannot fire + 4. the orchestrator reads success, old_lines == new_lines, and proceeds + 5. it vectorizes and stores those 12 entries in ChromaDB + 6. the file is untouched, so the next run extracts the SAME 12 and stores + them AGAIN + + That is not a skip loop, it is a duplicate-vector loop: @seedgo's global + count climbed every run while their file never moved. A silent write + failure in an archiver is the one failure mode that must never be silent, + because the archive keeps accepting what the source never gave up. + """ + + def test_a_refused_write_raises_instead_of_returning_quietly(self, monkeypatch, tmp_path): + ext, _ = _import_extractor(monkeypatch) + target = tmp_path / "local.json" + target.write_text("{}", encoding="utf-8") + + monkeypatch.setattr(ext, "write_memory_file_simple", lambda *a, **k: False) + + with pytest.raises(OSError): + ext._write_memory_file(target, {"sessions": []}) + + def test_a_successful_write_still_returns_quietly(self, monkeypatch, tmp_path): + ext, _ = _import_extractor(monkeypatch) + target = tmp_path / "local.json" + target.write_text("{}", encoding="utf-8") + + monkeypatch.setattr(ext, "write_memory_file_simple", lambda *a, **k: True) + + assert ext._write_memory_file(target, {"sessions": []}) is None + + def test_the_refusal_reaches_the_caller_as_a_failed_extraction(self, monkeypatch, tmp_path): + """The whole point: a refused write must NOT be reported as archived. + + Without this the orchestrator vectorizes entries the file still holds. + """ + ext, mocks = _import_extractor(monkeypatch) + branch_key = tmp_path.name.lower() + data = { + "document_metadata": {"schema_version": "3.0.0", "status": {}}, + "sessions": [ + {"number": n, "date": "2026-01-0%d" % (n % 9 + 1), "summary": f"s{n}", "status": "completed"} + for n in range(6, 0, -1) + ], + } + file_path = tmp_path / ".trinity" / "local.json" + file_path.parent.mkdir(parents=True) + file_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + mocks["config_loader"].section.return_value = { + "defaults": {}, + "per_branch": {branch_key: {"local": {"sessions": {"count": 2}}}}, + } + mocks["memory_files"].read_memory_file_data.return_value = data + monkeypatch.setattr(ext, "write_memory_file_simple", lambda *a, **k: False) + + result = ext.extract_items(file_path) + + assert result["success"] is False + assert "extracted" not in result or not result.get("extracted") + + +class TestTodayIsNotEvidenceAgainstANumberedEntry: + """The skip loop the valve's own alarm predicted, met in the wild 2026-08-30. + + @memory wrote 27 key_learnings in one very long day across three sessions. + Every entry was correctly prepended, strictly newest-first, monotonically + numbered 135 down to 109 — and every entry was dated today, because it WAS + today. So all 12 archivable candidates were refused as "fresh writes", the + file stayed at 27/15, and the detector re-fired on it every single run. + Three branches were in that state at once (memory, seedgo, daemon). + + The valve's job is catching a fresh write that landed at the WRONG END. The + number is what says which end an entry is at; the date was only ever a proxy + for lanes where the number cannot answer. When both numbers are usable and + the candidate is strictly below the head, ordering has already decided, and + a proxy that overrules the thing it stands in for is not a safety valve. + + What is deliberately NOT weakened: an entry numbered ABOVE the head is still + refused (that is the real convention-loss shape — prepend became append, so + numbers ascend into the tail), and an entry with no usable number on either + side is still refused on its date, because there ordering genuinely cannot + decide. + """ + + def test_a_days_worth_of_correctly_ordered_entries_can_drain(self, monkeypatch, caplog): + ext, _ = _import_extractor(monkeypatch) + today = datetime.now().strftime("%Y-%m-%d") + entries = [{"number": n, "date": today} for n in range(135, 108, -1)] + assert len(entries) == 27 + + with caplog.at_level(logging.WARNING): + archivable = ext._extract_tail_excess( + entries, 15, entries[0]["number"], "key_learnings", "memory", head_date=today + ) + + assert [e["number"] for e in archivable] == list(range(120, 108, -1)) + assert "NOTHING DRAINED" not in caplog.text + + def test_an_entry_numbered_above_the_head_is_still_refused(self, monkeypatch): + """The real convention-loss shape survives the narrowing.""" + ext, _ = _import_extractor(monkeypatch) + today = datetime.now().strftime("%Y-%m-%d") + entries = [{"number": n, "date": today} for n in range(20, 10, -1)] + entries.append({"number": 99, "date": today, "why": "prepend became append"}) + + archivable = ext._extract_tail_excess( + entries, 5, entries[0]["number"], "key_learnings", "victim", head_date=today + ) + + assert 99 not in [e["number"] for e in archivable] + + def test_a_tail_entry_numbered_EQUAL_to_the_head_is_still_refused(self, monkeypatch): + """The `<` in `number < head_number` is load-bearing and nothing pinned it. + + A duplicate of the head sitting at the tail is the one shape ordering + genuinely cannot separate: it is not above the head, so the + convention-loss rule misses it, and it is not below the head either, so + it has no claim to being older. The date rule has to decide, even when + the head is dated today. + + Caught by a mutant that forced the ordering flag to True — which passed + the whole suite until this existed. + """ + ext, _ = _import_extractor(monkeypatch) + today = datetime.now().strftime("%Y-%m-%d") + entries = [{"number": n, "date": today} for n in range(20, 10, -1)] + entries.append({"number": 20, "date": today, "why": "duplicate of the head, at the tail"}) + + archivable = ext._extract_tail_excess( + entries, 5, entries[0]["number"], "key_learnings", "victim", head_date=today + ) + + assert all("why" not in e for e in archivable), archivable + + def test_key_learnings_drain_end_to_end_when_the_whole_array_is_todays(self, monkeypatch, tmp_path): + """The head_date WIRING, not just the predicate. + + The predicate tests call `_extract_tail_excess` directly and pass + head_date themselves, so they cannot see whether `_extract_items_v2` + actually threads it. A mutant passing head_date=None for key_learnings + survived the entire suite. This drives the real path: @memory's live + shape on 2026-08-30 — every key_learning correctly ordered, every one + dated today. + """ + ext, mocks = _import_extractor(monkeypatch) + branch_key = tmp_path.name.lower() + today = datetime.now().strftime("%Y-%m-%d") + data = { + "document_metadata": {"schema_version": "3.0.0", "status": {}}, + "key_learnings": [{"number": n, "date": today, "key": f"k{n}", "value": f"v{n}"} for n in range(27, 0, -1)], + } + file_path = tmp_path / ".trinity" / "local.json" + file_path.parent.mkdir(parents=True) + file_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + mocks["config_loader"].section.return_value = { + "defaults": {}, + "per_branch": {branch_key: {"local": {"key_learnings": {"count": 15}}}}, + } + mocks["memory_files"].read_memory_file_data.return_value = data + mocks["memory_files"].write_memory_file_simple.return_value = True + + result = ext.extract_items(file_path) + + assert result["success"] is True + assert result.get("skipped") is not True, "the whole point is that it does NOT skip" + assert result["extracted_count"] == 12, result.get("extracted_count") + assert [e["number"] for e in result["extracted"]] == list(range(12, 0, -1)) + + def test_a_numberless_entry_dated_today_is_still_refused(self, monkeypatch): + """Where ordering cannot decide, the date rule is all there is.""" + ext, _ = _import_extractor(monkeypatch) + today = datetime.now().strftime("%Y-%m-%d") + entries = [{"number": n, "date": "2020-01-01"} for n in range(20, 10, -1)] + entries.append({"date": today, "summary": "no number at all"}) + + archivable = ext._extract_tail_excess( + entries, 5, entries[0]["number"], "key_learnings", "victim", head_date=today + ) + + assert all("summary" not in e for e in archivable) + + class TestNewestFirstOrderingGuard: """Rollover must not trust stored order — the tail is only 'oldest' if the array is newest-first.""" @@ -1260,6 +1494,29 @@ def test_displays_failure_details(self, monkeypatch): rollover.run_rollover() mocks["error"].assert_called() + def test_a_run_where_everything_failed_still_states_its_score(self, monkeypatch): + """0/1 is a result. Printing nothing lets a total failure read as a quiet run. + + The completion line was gated on `success_count > 0`, so a rollover in + which every trigger failed ended on a blank line under "Found 1 files + ready for rollover". The per-failure detail was there, but the run never + said what it had achieved overall — and "no summary" is the same shape + on screen as "nothing needed doing". + """ + rollover, mocks = _import_rollover_module(monkeypatch) + mocks["orchestrator"].execute_rollover.return_value = { + "success": False, + "triggers_count": 3, + "success_count": 0, + "failed": [{"trigger": "BAD.local.json", "stage": "extraction", "error": "write refused"}], + "results": [], + } + + rollover.run_rollover() + + printed = " ".join(str(c) for c in mocks["console"].print.call_args_list) + assert "0/3" in printed, printed + # =========================================================================== # Tests: modules.rollover.show_status @@ -1618,3 +1875,250 @@ def test_the_valve_still_holds_every_misplaced_entry_back(self): entries = [{"number": 65, "date": "2026-01-01", "value": "head"}] + self._misplaced(96) kept, _warnings, _debugs = self._run(entries, 15, 65) assert kept == [] + + +class TestASkippedTriggerIsNotSilentlyDropped: + """A trigger counted as neither success nor failure is a hole in the tally. + + Same species as the 0/N silence fixed earlier: the detector says a file is + ready, the extractor finds nothing to archive, and the loop ``continue``s + without touching ``success_count`` OR ``failed``. The run then reports + ``0/1 successful`` with an empty failure list — a number that says something + went wrong and a list that says nothing did. Whoever reads that output has + to guess, and the file is left in whatever state the detector objected to. + + The extractor's own skip path can even have WRITTEN the file (a newest-first + order repair with nothing to archive persists), so "skipped" is not reliably + "nothing happened" either. + """ + + @staticmethod + def _trigger(name="guinea.local"): + trigger = MagicMock() + trigger.__str__ = lambda self: name + trigger.file_path = Path("/tmp/does-not-matter/local.json") + trigger.branch = "guinea" + trigger.memory_type = "local" + return trigger + + def _run_with_skip(self): + from aipass.memory.apps.handlers.rollover import orchestrator + + with ( + patch.object( + orchestrator.detector, + "check_all_branches", + return_value={"success": True, "triggers": [self._trigger()]}, + ), + patch.object( + orchestrator.extractor, + "create_rollover_backup", + return_value={"success": True, "message": "backed up"}, + ), + patch.object( + orchestrator.extractor, + "extract_with_metadata", + return_value={"success": True, "skipped": True, "message": "No entries exceed v2 limits"}, + ), + ): + return orchestrator.execute_rollover() + + def test_a_skipped_trigger_is_reported_somewhere(self): + result = self._run_with_skip() + accounted = result["success_count"] + len(result["failed"]) + len(result.get("skipped", [])) + assert accounted == result["triggers_count"], ( + f"1 trigger in, {accounted} accounted for: {result['success_count']} succeeded, " + f"{len(result['failed'])} failed, {len(result.get('skipped', []))} skipped" + ) + + def test_the_skip_carries_the_reason_the_extractor_gave(self): + skipped = self._run_with_skip().get("skipped", []) + assert skipped, "the skip must be reported, not dropped" + assert "exceed" in skipped[0]["reason"], skipped[0] + + def test_a_skipped_trigger_is_not_counted_as_a_success(self): + """The file was not archived. Calling it a success would be the lie.""" + assert self._run_with_skip()["success_count"] == 0 + + def test_a_run_that_only_skipped_did_not_fail(self): + """Nothing broke — 'nothing to do' is a legitimate outcome, just a named one.""" + assert self._run_with_skip()["success"] is True + + +class TestASkippedTriggerIsVisibleOnScreen: + """The handler counting it is only half — the operator has to be able to read it.""" + + def test_the_skip_and_its_reason_are_printed(self, monkeypatch): + rollover, mocks = _import_rollover_module(monkeypatch) + mocks["orchestrator"].execute_rollover.return_value = { + "success": True, + "triggers_count": 1, + "success_count": 0, + "failed": [], + "skipped": [{"trigger": "guinea.local (16/15 sessions)", "reason": "No entries exceed v2 limits"}], + "results": [], + } + + rollover.run_rollover() + + printed = " ".join(str(c) for c in mocks["console"].print.call_args_list) + assert "0/1" in printed, printed + assert "guinea.local" in printed and "skipped" in printed, printed + + def test_a_run_with_no_skips_prints_no_skip_line(self, monkeypatch): + """The absence of a category must not print an empty heading.""" + rollover, mocks = _import_rollover_module(monkeypatch) + mocks["orchestrator"].execute_rollover.return_value = { + "success": True, + "triggers_count": 1, + "success_count": 1, + "failed": [], + "skipped": [], + "results": [], + } + + rollover.run_rollover() + + assert "skipped" not in " ".join(str(c) for c in mocks["console"].print.call_args_list) + + +# =========================================================================== +# THE LANE AGAINST THE REAL WRITE GATE (2026-08-30) +# =========================================================================== +# +# Every test above imports the extractor with `memory_files` MOCKED — correct +# for testing extraction logic, and blind to the one thing that actually took +# this lane down. +# +# On 2026-08-30 the cap gate refused rollover's write for an entry in the head +# it never touched, and the lane re-failed identically every 20 minutes for +# three hours against @seedgo's real file. When the rule was fixed, restoring +# the defect as a mutant killed exactly three tests — all of them in +# test_changed_entries.py, at the WRITER. Not one rollover test died. The suite +# for the lane that broke could not see what broke it, because it mocks the +# component that refused. +# +# @hooks found the mirror image of this in their own tree the same evening: a +# mutant that made their checker return NOTHING left all 115 of their +# end-to-end tests green, because the union ran @memory's real diff. When two +# halves overlap, a suite that cannot tell them apart proves neither. +# +# So this class mocks NOTHING below the extractor. Real memory_files, real +# entry_limits, real caps, a real file on disk — and it asserts on what is +# actually written, because "success" is what the lane reported for three +# hours while the file never changed. + + +class TestRolloverSurvivesCarriedDebt: + """The live deadlock, driven through the extractor against the real writer.""" + + @pytest.fixture + def ext(self, monkeypatch): + """The extractor with its REAL dependencies — the point of this class. + + `_import_extractor` above leaves a cached extractor module bound to a + MagicMock `memory_files`, and that binding outlives the monkeypatch + that created it: restoring sys.modules does not re-bind names already + imported into a cached module. Without this eviction these tests pass + alone and fail in the suite, reading `read_memory_file_data` as a mock + returning None. + + Evicted with `monkeypatch.delitem`, never a bare `sys.modules.pop` — a + bare pop is one-way and outlives the test, which is how two receipt + tests went red on a single xdist worker in session 163. + """ + for name in ( + "aipass.memory.apps.handlers.json", + "aipass.memory.apps.handlers.json.json_handler", + "aipass.memory.apps.handlers.json.config_loader", + "aipass.memory.apps.handlers.json.entry_limits", + "aipass.memory.apps.handlers.json.memory_files", + "aipass.memory.apps.handlers.rollover.extractor", + ): + monkeypatch.delitem(sys.modules, name, raising=False) + parent = sys.modules.get("aipass.memory.apps.handlers.rollover") + if parent is not None and hasattr(parent, "extractor"): + monkeypatch.delattr(parent, "extractor", raising=False) + + from aipass.memory.apps.handlers.rollover import extractor + + # Prove the real writer is wired, not a mock. A test class whose whole + # purpose is "run against the real gate" must not silently run against + # a double. + assert isinstance(extractor.write_memory_file_simple, types.FunctionType), ( + "extractor is bound to a mocked writer — this class would prove nothing" + ) + return extractor + + @staticmethod + def _seedgo_shaped_file(tmp_path): + """A file over its session count, carrying one over-cap summary in the HEAD. + + The 343-char summary is @seedgo's real shape. It sits at the top, where + rollover archives from the BOTTOM — so the lane can never shrink it, + and a gate that refuses the document for it can never be satisfied. + """ + trinity = tmp_path / "standin" / ".trinity" + trinity.mkdir(parents=True) + file_path = trinity / "local.json" + data = { + "document_metadata": { + "document_type": "session_history", + "document_name": "standin.LOCAL", + "version": "2.0.0", + "schema_version": "3.0.0", + }, + "sessions": [{"number": 20, "date": "2026-08-30", "summary": "X" * 343}] + + [{"number": n, "date": "2026-08-29", "summary": f"session {n}"} for n in range(19, 0, -1)], + } + file_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + return file_path + + def test_entries_actually_leave_the_file(self, ext, tmp_path): + """Not "reported success" — MEASURED on disk, which is where the lie was.""" + file_path = self._seedgo_shaped_file(tmp_path) + before_count = len(json.loads(file_path.read_text(encoding="utf-8"))["sessions"]) + + result = ext.extract_items(file_path) + + assert result["success"] is True, result.get("error") + after = json.loads(file_path.read_text(encoding="utf-8"))["sessions"] + assert len(after) < before_count, "the lane reported success and the file never shrank" + assert result["extracted_count"] == before_count - len(after), ( + "extracted count disagrees with what left the file — the 3-hour lie exactly" + ) + + def test_the_carried_entry_is_still_there_and_still_over_cap(self, ext, tmp_path): + """Rollover must not 'fix' the fat entry — it is not rollover's to touch. + + The cure for a carried entry is its own agent trimming it, or the entry + ageing into the tail and being archived whole. A shrink lane that + started editing text to satisfy a cap would be authoring memories. + """ + file_path = self._seedgo_shaped_file(tmp_path) + result = ext.extract_items(file_path) + + # Assert the run DID something first. A refused write leaves the file + # untouched, so "the fat entry is still there" would pass on a run that + # archived nothing — the test would be green about a dead lane. + assert result["success"] is True, result.get("error") + assert result["extracted_count"] > 0, "nothing was archived — this pin would pass vacuously" + + head = json.loads(file_path.read_text(encoding="utf-8"))["sessions"][0] + assert head["summary"] == "X" * 343, "rollover edited an entry it only moves past" + + def test_a_second_run_is_not_re_archiving_the_same_entries(self, ext, tmp_path): + """The duplicate-work loop the refusal caused, pinned from the outside. + + While the write was refused the file never changed, so every run + extracted and vectorised the same entries again. If the file shrinks, + the second run has strictly less to do. + """ + file_path = self._seedgo_shaped_file(tmp_path) + first = ext.extract_items(file_path) + second = ext.extract_items(file_path) + + assert first["extracted_count"] > 0 + assert second.get("extracted_count", 0) < first["extracted_count"], ( + "the second run extracted as much as the first — the file did not shrink" + ) diff --git a/src/aipass/memory/tests/test_roots_lifecycle.py b/src/aipass/memory/tests/test_roots_lifecycle.py new file mode 100644 index 000000000..a7fa54dd8 --- /dev/null +++ b/src/aipass/memory/tests/test_roots_lifecycle.py @@ -0,0 +1,498 @@ +# =================== AIPass ==================== +# Name: test_roots_lifecycle.py +# Description: Pins the template, verbs and healing for AIPASS_ROOTS.json +# Version: 1.0.0 +# Created: 2026-08-30 +# Modified: 2026-08-30 +# ============================================= + +"""The anchor is written by code, from a template, or it is not written. + +FPLAN-0460 phase 4. @devpulse created AIPASS_ROOTS.json by hand this morning and +Patrick ruled on seeing it, verbatim: "jsons are normally created by code, so if +they corrupt or get deleted they are always rebuilt from default settings from a +template directory." + +THE ASYMMETRY THAT SHAPES ALL OF THIS. The reader already refuses a row it cannot +use -- a path that does not exist, one that overlaps AIPass home, a duplicate. +Refusing at READ time means the file is allowed to carry a row that will never be +honoured, and the only place that fact appears is a log line nobody is reading. +So every one of those refusals now happens at WRITE time too, against the same +predicate, so the file cannot contain a declaration the reader will silently +drop. + +HEALING IS A SEPARATE, DELIBERATE VERB, never a side effect of reading. Stated +here because it is a design ruling and not an implementation detail: an automatic +rebuild would replace Patrick's declarations with an empty scaffold as a +side effect of any lane that happened to read the file first -- rollover, lint, +health, @daemon's scheduler -- and because ZERO ROOTS IS A LEGAL STATE, nothing +downstream would fail. The system would keep running and quietly maintain +nothing. That is re-declaring on Patrick's behalf, which is exactly what +declaration-is-the-credential forbids. Same principle as the One Law's fourth +hat: unreadable gold REFUSES rather than scoring zero. + +So: the reader keeps refusing, exactly as it does today, and ``roots heal`` is +the one thing that may write a scaffold over a broken file -- after preserving +the original bytes and printing what it could not carry across. +""" + +import json +import pathlib +from pathlib import Path + +import pytest + +from aipass.memory.apps.handlers.monitor import registry_scope as rs +from aipass.memory.apps.handlers.monitor import roots_file as rf + + +TODAY = "2026-08-30" + + +@pytest.fixture +def home(tmp_path): + """An AIPass home with a real sibling to declare, and one that overlaps.""" + root = tmp_path / "AIPass" + (root / "projects" / "inside").mkdir(parents=True) + (root / "AIPASS_REGISTRY.json").write_text(json.dumps({"branches": []}), encoding="utf-8") + (tmp_path / "wren").mkdir() + (tmp_path / "Demo").mkdir() + (tmp_path / "loose.txt").write_text("not a repo", encoding="utf-8") + return root + + +def _read(home_path): + return json.loads((home_path / rs.DECLARED_ROOTS).read_text(encoding="utf-8")) + + +class TestTheTemplateIsTheDefaultShape: + """A template that has drifted from the reader is worse than no template.""" + + def test_the_template_ships_in_the_branch(self): + assert rf.template_path().is_file(), f"no template at {rf.template_path()}" + + def test_the_template_declares_nothing(self): + """An empty roots[] is the only honest default: nobody has declared yet.""" + assert json.loads(rf.template_path().read_text(encoding="utf-8"))["roots"] == [] + + def test_the_scaffold_is_empty_even_if_the_template_stops_being(self, tmp_path, monkeypatch): + """Belt and braces, made load-bearing rather than left as decoration. + + Found by mutation: dropping the ``roots = []`` line in the renderer + survived, because today's template happens to be empty. The line is the + guarantee that a template edited to carry an example root cannot declare + on an installation's behalf the moment it renders — so it is pinned + against a template that does exactly that. + """ + stub = tmp_path / "stub.template.json" + stub.write_text( + json.dumps({"metadata": {"version": "1.0.0"}, "roots": [{"path": "../example", "status": "active"}]}), + encoding="utf-8", + ) + monkeypatch.setattr(rf, "template_path", lambda: stub) + assert rf.render_scaffold(TODAY)["roots"] == [] + + def test_the_rendered_scaffold_is_what_the_reader_accepts(self, home): + """The template and the reader are pinned to each other, not just to a schema. + + A scaffold the reader rejects would be a template that produces a file + needing repair the moment it is created. + """ + rf.init_roots(home, today=TODAY) + assert rs.declared_roots(home) == [] + document = _read(home) + assert document["roots"] == [] + assert document["metadata"]["version"] == rf.ROOTS_SCHEMA_VERSION + assert document["metadata"]["last_updated"] == TODAY + + +class TestInitNeverClobbers: + """The one file whose accidental overwrite loses declarations nobody else holds.""" + + def test_init_creates_it_when_absent(self, home): + ok, message = rf.init_roots(home, today=TODAY) + assert ok, message + assert (home / rs.DECLARED_ROOTS).is_file() + + def test_init_refuses_when_it_already_exists(self, home): + rf.init_roots(home, today=TODAY) + rf.add_root(home, "../wren", today=TODAY) + ok, message = rf.init_roots(home, today=TODAY) + assert not ok + assert rs.DECLARED_ROOTS in message + assert [row["path"] for row in _read(home)["roots"]] == ["../wren"], "init overwrote a declaration" + + def test_init_refuses_a_corrupt_file_too(self, home): + """Unreadable is not absent. Overwriting it here would be the silent rebuild.""" + (home / rs.DECLARED_ROOTS).write_text("{ broken", encoding="utf-8") + ok, _ = rf.init_roots(home, today=TODAY) + assert not ok + assert (home / rs.DECLARED_ROOTS).read_text(encoding="utf-8") == "{ broken" + + +class TestAddRefusesAtWriteWhatTheReaderRefusesAtRead: + """One predicate, two enforcement points. Never two predicates.""" + + def test_a_real_sibling_is_declared_and_relative(self, home): + rf.init_roots(home, today=TODAY) + ok, message = rf.add_root(home, str(home.parent / "wren"), today=TODAY) + assert ok, message + row = _read(home)["roots"][0] + assert row["path"] == "../wren", "an absolute path was stored where a relative one fits" + assert row["status"] == "active" + assert [path.name for path in rs.declared_roots(home)] == ["wren"] + + def test_a_path_that_does_not_exist_is_refused_by_that_name(self, home): + """The MESSAGE is the pin, because the guard is otherwise redundant. + + Found by mutation: deleting the existence check survived, since + ``Path.is_dir()`` is already False for a path that is not there. What + the check actually buys is a different DIAGNOSIS — "you named something + that is not on this machine" and "you named something that is not a + directory" send an operator to two different places. A branch that + exists only for its wording is pinned on its wording or it is dead code. + """ + rf.init_roots(home, today=TODAY) + ok, message = rf.add_root(home, "../nowhere", today=TODAY) + assert not ok + assert "does not exist" in message + assert _read(home)["roots"] == [] + + def test_a_file_outside_home_is_refused_as_not_a_directory(self, home): + """Outside home DELIBERATELY: a file inside it is refused by the overlap + guard first, so testing that one proved nothing about this one — which + is exactly how the missing case hid.""" + rf.init_roots(home, today=TODAY) + ok, message = rf.add_root(home, "../loose.txt", today=TODAY) + assert not ok + assert "not a directory" in message + assert _read(home)["roots"] == [] + + @pytest.mark.parametrize("candidate", [".", "projects/inside", ".."]) + def test_a_root_overlapping_home_is_refused_at_write_time(self, home, candidate): + """The double-count guard, moved forward to where it can be answered. + + The reader refuses these already. Refusing them here means the file + never carries a row whose only trace of being wrong is a log line. + """ + rf.init_roots(home, today=TODAY) + ok, message = rf.add_root(home, candidate, today=TODAY) + assert not ok + assert "overlap" in message.lower() + assert _read(home)["roots"] == [] + + def test_the_same_root_cannot_be_declared_twice(self, home): + rf.init_roots(home, today=TODAY) + rf.add_root(home, "../wren", today=TODAY) + ok, message = rf.add_root(home, str(home.parent / "wren"), today=TODAY) + assert not ok, "the same directory was declared twice under two spellings" + assert "already" in message.lower() + assert len(_read(home)["roots"]) == 1 + + def test_the_label_defaults_to_the_directory_name(self, home): + rf.init_roots(home, today=TODAY) + rf.add_root(home, "../wren", today=TODAY) + assert _read(home)["roots"][0]["label"] == "wren" + + def test_a_given_label_is_kept(self, home): + rf.init_roots(home, today=TODAY) + rf.add_root(home, "../wren", label="the-fence", today=TODAY) + assert _read(home)["roots"][0]["label"] == "the-fence" + + def test_adding_stamps_the_file(self, home): + rf.init_roots(home, today="2026-01-01") + rf.add_root(home, "../wren", today=TODAY) + assert _read(home)["metadata"]["last_updated"] == TODAY + + def test_add_refuses_when_the_file_is_absent(self, home): + """Never create by side effect. `init` is the verb that creates.""" + ok, message = rf.add_root(home, "../wren", today=TODAY) + assert not ok + assert "init" in message.lower() + assert not (home / rs.DECLARED_ROOTS).exists() + + +class TestRemoveTakesOnlyWhatIsThere: + def test_a_declared_root_is_removed_by_any_spelling(self, home): + rf.init_roots(home, today=TODAY) + rf.add_root(home, "../wren", today=TODAY) + rf.add_root(home, "../Demo", today=TODAY) + ok, message = rf.remove_root(home, str(home.parent / "wren"), today=TODAY) + assert ok, message + assert [row["path"] for row in _read(home)["roots"]] == ["../Demo"] + + def test_removing_something_never_declared_is_refused_not_ignored(self, home): + """A no-op that reports success teaches the operator the wrong thing.""" + rf.init_roots(home, today=TODAY) + rf.add_root(home, "../wren", today=TODAY) + ok, message = rf.remove_root(home, "../Demo", today=TODAY) + assert not ok + assert "not declared" in message.lower() + assert len(_read(home)["roots"]) == 1 + + def test_a_root_that_no_longer_exists_on_disk_can_still_be_removed(self, home): + """Retiring a deleted repo must not require resurrecting it first.""" + rf.init_roots(home, today=TODAY) + rf.add_root(home, "../Demo", today=TODAY) + (home.parent / "Demo").rmdir() + ok, message = rf.remove_root(home, "../Demo", today=TODAY) + assert ok, message + assert _read(home)["roots"] == [] + + +class TestListReportsResolutionNotJustRows: + def test_it_reports_what_each_row_resolves_to(self, home): + rf.init_roots(home, today=TODAY) + rf.add_root(home, "../wren", today=TODAY) + rows = rf.list_roots(home) + assert len(rows) == 1 + assert rows[0]["path"] == "../wren" + assert rows[0]["resolves"] == home.parent / "wren" + assert rows[0]["reachable"] is True + + def test_a_row_the_reader_would_drop_is_shown_as_unreachable(self, home): + """The whole point of a list verb: show the rows that are quietly dead.""" + rf.init_roots(home, today=TODAY) + rf.add_root(home, "../Demo", today=TODAY) + (home.parent / "Demo").rmdir() + assert rf.list_roots(home)[0]["reachable"] is False + + def test_listing_an_absent_file_is_empty_and_not_an_error(self, home): + assert rf.list_roots(home) == [] + + +class TestHealingIsDeliberateAndNeverReDeclares: + """The ruling, pinned: a rebuild may lose declarations but must never invent them.""" + + def test_reading_a_corrupt_file_does_not_heal_it(self, home): + """The pin that keeps healing out of the read path. + + If any lane could trigger a rebuild by reading, a corrupt file would be + replaced with an empty scaffold as a side effect, and because zero roots + is a LEGAL state nothing downstream would fail. This asserts the reader + stayed a reader. + """ + (home / rs.DECLARED_ROOTS).write_text("{ broken", encoding="utf-8") + assert rs.declared_roots(home) == [] + assert (home / rs.DECLARED_ROOTS).read_text(encoding="utf-8") == "{ broken" + + def test_heal_refuses_a_healthy_file(self, home): + rf.init_roots(home, today=TODAY) + rf.add_root(home, "../wren", today=TODAY) + ok, message, salvaged = rf.heal(home, today=TODAY) + assert not ok + assert "nothing to heal" in message.lower() + assert salvaged == [] + assert len(_read(home)["roots"]) == 1 + + def test_heal_rebuilds_an_empty_scaffold_never_a_populated_one(self, home): + (home / rs.DECLARED_ROOTS).write_text('{"roots": [{"path": "../wren"', encoding="utf-8") + ok, message, salvaged = rf.heal(home, today=TODAY) + assert ok, message + assert _read(home)["roots"] == [], "healing re-declared on the operator's behalf" + + def test_heal_preserves_the_original_bytes_rather_than_deleting_them(self, home): + broken = '{"roots": [{"path": "../wren"' + (home / rs.DECLARED_ROOTS).write_text(broken, encoding="utf-8") + rf.heal(home, today=TODAY) + preserved = home / rf.CORRUPT_SUFFIX_NAME + assert preserved.is_file(), "the corrupt file was destroyed instead of set aside" + assert preserved.read_text(encoding="utf-8") == broken + + def test_heal_reports_the_declarations_it_could_not_carry_across(self, home): + """Report, never restore. The strings are for a human to re-declare.""" + (home / rs.DECLARED_ROOTS).write_text( + '{"roots": [{"path": "../wren"}, {"path": "../Demo"} BROKEN', encoding="utf-8" + ) + ok, _, salvaged = rf.heal(home, today=TODAY) + assert ok + assert set(salvaged) == {"../wren", "../Demo"} + assert _read(home)["roots"] == [], "a salvaged path was silently re-declared" + + def test_heal_refuses_when_there_is_no_file_at_all(self, home): + """Absent is `init`'s job. Two verbs that both create is one too many.""" + ok, message, _ = rf.heal(home, today=TODAY) + assert not ok + assert "init" in message.lower() + + +class TestOnePredicateNotTwo: + """The write-time guard must BE the read-time guard, not agree with it.""" + + def test_the_overlap_rule_is_the_readers_own(self): + assert rf.overlaps_home is rs.overlaps_home + + def test_the_filename_is_the_readers_own(self): + assert rf.ROOTS_FILE is rs.DECLARED_ROOTS + + +class TestTheLiveFileMatchesWhatTheVerbsWouldProduce: + """Task 4: adopt Patrick's hand-made file, or say exactly how it differs.""" + + def test_the_blessed_declarations_survive_a_regeneration(self, tmp_path): + live = rs.find_repo_root() / rs.DECLARED_ROOTS + if not live.is_file(): + pytest.skip(f"no live {rs.DECLARED_ROOTS} on this machine -- adoption guard skipped") + blessed = json.loads(live.read_text(encoding="utf-8")) + + stand_in = tmp_path / "AIPass" + stand_in.mkdir() + (stand_in / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") + for row in blessed["roots"]: + (stand_in.parent / Path(row["path"]).name).mkdir(exist_ok=True) + rf.init_roots(stand_in, today=blessed["metadata"]["last_updated"]) + for row in blessed["roots"]: + ok, message = rf.add_root( + stand_in, row["path"], label=row["label"], today=blessed["metadata"]["last_updated"] + ) + assert ok, f"the verbs refuse a blessed declaration: {message}" + + rebuilt = json.loads((stand_in / rs.DECLARED_ROOTS).read_text(encoding="utf-8")) + assert rebuilt["roots"] == blessed["roots"], ( + "the verbs do not reproduce Patrick's declarations -- his blessing is what must survive" + ) + + def test_the_live_file_is_exactly_what_the_verbs_produce(self, tmp_path): + """After adoption there is no daylight between the file and its generator. + + The declarations are Patrick's and are asserted above; THIS is the pin + that says the hand-made file has been brought under code management + rather than merely tolerated. If it fails, the live file was edited by + hand again. + """ + live = rs.find_repo_root() / rs.DECLARED_ROOTS + if not live.is_file(): + pytest.skip(f"no live {rs.DECLARED_ROOTS} on this machine -- adoption guard skipped") + blessed = json.loads(live.read_text(encoding="utf-8")) + + stand_in = tmp_path / "AIPass" + stand_in.mkdir() + (stand_in / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") + for row in blessed["roots"]: + (stand_in.parent / Path(row["path"]).name).mkdir(exist_ok=True) + rf.init_roots(stand_in, today=blessed["metadata"]["last_updated"]) + for row in blessed["roots"]: + rf.add_root(stand_in, row["path"], label=row["label"], today=blessed["metadata"]["last_updated"]) + + produced = (stand_in / rs.DECLARED_ROOTS).read_text(encoding="utf-8") + assert produced == live.read_text(encoding="utf-8"), ( + "the live AIPASS_ROOTS.json is not byte-identical to what the verbs render" + ) + + +class TestTheOperatorLane: + """The CLI, tested AFTER the handler rather than before it — stated, not hidden. + + The handler below it was written red-first; this layer was not, so every pin + here is proven by mutation instead of by having watched it fail. That is a + weaker guarantee and it is named as one. + """ + + def test_it_answers_only_its_own_command(self): + from aipass.memory.apps.modules import roots + + assert roots.handle_command("rollover", []) is False + assert roots.handle_command("search", ["x"]) is False + + @pytest.mark.parametrize("args", [[], ["--help"], ["-h"], ["help"], ["add", "--help"]]) + def test_help_is_reachable_from_any_position(self, capsys, args): + """`roots add --help` is what a person types when the order escapes them. + + A gate reading args[0] alone treats that as a path and refuses, which is + the worst possible answer to a request for help. + """ + from aipass.memory.apps.modules import roots + + assert roots.handle_command("roots", args) is True + assert "roots Module" in capsys.readouterr().out + + def test_an_unknown_subcommand_is_named_not_swallowed(self, capsys): + from aipass.memory.apps.modules import roots + + assert roots.handle_command("roots", ["nonsense"]) is True + captured = capsys.readouterr() + assert "nonsense" in captured.out + captured.err + + @pytest.mark.parametrize("verb", ["add", "remove"]) + def test_a_verb_that_needs_a_path_refuses_without_one(self, capsys, verb): + """Never operate on a default. A missing path is a question, not a zero.""" + from aipass.memory.apps.modules import roots + + assert roots.handle_command("roots", [verb]) is True + captured = capsys.readouterr() + assert "needs a path" in captured.out + captured.err + + def test_the_module_is_discovered_but_the_gateway_is_not_this(self): + """Two modules, two jobs: the library door stays a door. + + ``modules/fleet.py`` carries a pin that its command surface computes + nothing. These verbs write files, which is why they are not on it. + """ + from aipass.memory.apps.modules import fleet, roots + + assert hasattr(roots, "handle_command") + assert not hasattr(fleet, "add_root"), "the write lane leaked onto the read gateway" + + +class _WindowsFlavour(pathlib.PureWindowsPath): + """A path that spells itself the way Windows does, on any machine. + + ``_spell`` is a STRING contract, and on Linux every separator is already a + forward slash — so a test that just asserts "no backslash" passes whether + or not the code does anything, which is a green light wired to nothing. The + flavour is what has to be injected for the pin to have teeth here. + + ``resolve`` returns self because these paths are already absolute and there + is no filesystem behind them; the walk being pinned is arithmetic on names. + """ + + def resolve(self): + return self + + +class TestTheDeclaredSpellingIsPosixOnEveryMachine: + """One declaration, one spelling — decided after a Windows CI red. + + ``str()`` on a Windows path yields ``..\\wren``, so before this the same + ``roots add`` produced two different rows depending on which machine ran + it. Forward slashes are the only spelling BOTH platforms read: Windows + resolves ``../wren`` correctly, while ``..\\wren`` on POSIX is a FILENAME + containing a backslash, not a path — so the row that a Windows box wrote + would silently declare a root that does not exist when read back on Linux. + + The anchor is a file Patrick blesses. It has to read the same to a human on + either machine, and it has to diff. + """ + + def test_a_sibling_is_spelled_with_forward_slashes(self, monkeypatch): + monkeypatch.setattr(rf, "Path", _WindowsFlavour) + + spelled = rf._spell(_WindowsFlavour(r"C:\proj\AIPass"), _WindowsFlavour(r"C:\proj\wren")) + + assert spelled == "../wren" + + def test_an_absolute_declaration_is_spelled_with_forward_slashes_too(self, monkeypatch): + """The other return path. A contract honoured on one branch is a coincidence.""" + monkeypatch.setattr(rf, "Path", _WindowsFlavour) + + spelled = rf._spell(_WindowsFlavour(r"C:\proj\AIPass"), _WindowsFlavour(r"D:\elsewhere\wren")) + + assert spelled == "D:/elsewhere/wren" + + def test_no_declaration_this_verb_writes_can_carry_a_backslash(self, home): + """The end-to-end guard, in the file that ships.""" + rf.init_roots(home, today=TODAY) + rf.add_root(home, str(home.parent / "wren"), today=TODAY) + + assert "\\" not in (home / rs.DECLARED_ROOTS).read_text(encoding="utf-8") + + def test_the_reader_resolves_what_the_writer_spelled(self, home): + """The two halves are pinned against each other, not against a literal. + + A writer and a reader that agree because both were changed to match a + test string agree about the test. This asserts the round trip. + """ + rf.init_roots(home, today=TODAY) + rf.add_root(home, str(home.parent / "wren"), today=TODAY) + + assert rs.declared_roots(home) == [(home.parent / "wren").resolve()] diff --git a/src/aipass/memory/tests/test_symbolic.py b/src/aipass/memory/tests/test_symbolic.py index e69556f7d..31f12e011 100644 --- a/src/aipass/memory/tests/test_symbolic.py +++ b/src/aipass/memory/tests/test_symbolic.py @@ -93,6 +93,11 @@ def _mock_symbolic_infrastructure(monkeypatch): mock_deduplicator = MagicMock() symbolic_pkg = MagicMock() + + # (test_import_isolation.py) — a bare MagicMock has none, and any lazy + + # submodule import under it then dies with "is not a package". + symbolic_pkg.extractor = mock_extractor symbolic_pkg.storage = mock_storage symbolic_pkg.retriever = mock_retriever diff --git a/src/aipass/memory/tests/test_symbolic_cli.py b/src/aipass/memory/tests/test_symbolic_cli.py index 229a9e63d..2d068808d 100644 --- a/src/aipass/memory/tests/test_symbolic_cli.py +++ b/src/aipass/memory/tests/test_symbolic_cli.py @@ -114,6 +114,11 @@ def _mock_symbolic_infrastructure(monkeypatch): mock_hook.SESSION_STATE = {"messages_since_last": 0, "last_surface_time": 0} symbolic_pkg = MagicMock() + + # (test_import_isolation.py) — a bare MagicMock has none, and any lazy + + # submodule import under it then dies with "is not a package". + symbolic_pkg.extractor = mock_extractor symbolic_pkg.storage = mock_storage symbolic_pkg.retriever = mock_retriever diff --git a/src/aipass/memory/tests/test_symbolic_module.py b/src/aipass/memory/tests/test_symbolic_module.py index e7a6f9f6b..66fc52dba 100644 --- a/src/aipass/memory/tests/test_symbolic_module.py +++ b/src/aipass/memory/tests/test_symbolic_module.py @@ -140,6 +140,11 @@ def _mock_symbolic_infrastructure(monkeypatch): mock_hook.SESSION_STATE = {"messages_since_last": 0, "last_surface_time": 0} symbolic_pkg = MagicMock() + + # (test_import_isolation.py) — a bare MagicMock has none, and any lazy + + # submodule import under it then dies with "is not a package". + symbolic_pkg.extractor = mock_extractor symbolic_pkg.storage = mock_storage symbolic_pkg.retriever = mock_retriever diff --git a/src/aipass/memory/tests/test_trinity_standard.py b/src/aipass/memory/tests/test_trinity_standard.py index 4ee252cf9..37876f0ab 100644 --- a/src/aipass/memory/tests/test_trinity_standard.py +++ b/src/aipass/memory/tests/test_trinity_standard.py @@ -87,22 +87,25 @@ def test_a_new_list_shaped_entry_is_refused(self): assert hits[0]["reason"] == "unmeasurable" assert hits[0]["found_type"] == "list" - def test_a_legacy_unchanged_list_entry_is_now_refused(self): - """Rewritten 2026-08-27 — the fleet the exemption protected no longer exists. - - The nine branches carrying list-shaped notes were cured by the trinity - push; every one of those entries is in vectors and out of the files. - Keeping the exemption past that point protects nothing and hides the - next list-shaped note somebody writes. `todos` keep it, and only todos: - that is the one container no machine may prune, so refusing writes - there would brick the branch's rollover. + def test_a_legacy_unchanged_list_entry_is_carried_not_refused(self): + """Rewritten twice: refused 2026-08-27, carried again 2026-08-30. + + The 08-27 reasoning was that the push had cured the list-shaped-note + fleet, so the exemption protected nothing. What it missed is that this + gate never sees the writes that CAUSE drift — Bash lanes bypass @hooks + entirely — so refusing the next write cannot catch the drift, it can + only stop rollover, whose write is always the smaller document. The + entry is reported as carried debt instead; `lint` reads disk. """ legacy = {"number": 1, "note": [{"title": "x" * 500}]} before = {"observations": [legacy]} after = {"observations": [dict(legacy)]} - hits = el.changed_entries(before, after, _limits()) - assert len(hits) == 1 - assert hits[0]["key"] == "0" + + assert el.changed_entries(before, after, _limits()) == [] + + carried = el.classify_entries(before, after, _limits())["carried"] + assert len(carried) == 1 + assert carried[0]["key"] == "0" def test_the_exemption_survives_for_todos_alone(self): """The container the push may not cure keeps its rollover-safety.""" @@ -112,13 +115,20 @@ def test_the_exemption_survives_for_todos_alone(self): after = {"todos": [dict(legacy)]} assert el.changed_entries(before, after, limits) == [] - def test_both_list_entries_are_refused_in_an_archivable_container(self): - """Post-narrowing there is nothing to exempt here: both are reported.""" + def test_a_second_list_shaped_note_is_refused_beside_a_carried_one(self): + """The exemption must not license a NEW entry of the same broken shape. + + Identity is the raw value, never the sentinel: were every unmeasurable + entry to collapse to one None, a branch with a single legacy + list-shaped note could add ten more and each would read as "already on + disk". The new one is authored and refused; the legacy one is carried. + """ legacy = {"number": 1, "note": [{"title": "old"}]} before = {"observations": [legacy]} after = {"observations": [{"number": 2, "note": [{"title": "new"}]}, dict(legacy)]} - hits = el.changed_entries(before, after, _limits()) - assert {hit["key"] for hit in hits} == {"0", "1"} + + assert {hit["key"] for hit in el.changed_entries(before, after, _limits())} == {"0"} + assert {hit["key"] for hit in el.classify_entries(before, after, _limits())["carried"]} == {"1"} def test_a_second_list_shaped_todo_beside_a_legacy_one_is_still_refused(self): """The hole a text-identity dedup would leave, in the container that kept the exemption. @@ -747,26 +757,29 @@ def test_the_six_published_keys_are_still_ints(self): for key in ("entry_type", "container", "key"): assert isinstance(hit[key], str) - def test_untouched_legacy_drift_is_now_refused_too(self): - """Rewritten 2026-08-27: the push cured the renamed shape fleet-wide. + def test_untouched_renamed_field_drift_is_carried_not_refused(self): + """A renamed field deadlocks rollover exactly like an over-cap one. - The exemption existed so drifted branches could keep writing while the - reset was pending. The reset happened. What is left in an archivable - container is not legacy, it is new. + @ai_mail and @api carried `learning` where the config says `value`. + Refusing every write to those files would have bricked their rollover + for a shape only their own agent can rename — the same trade the + archiver always loses. """ legacy = {"number": 1, "observation": "x" * 500} before = {"observations": [legacy]} after = {"observations": [dict(legacy)]} - hits = el.changed_entries(before, after, _limits()) - assert len(hits) == 1 + + assert el.changed_entries(before, after, _limits()) == [] + assert len(el.classify_entries(before, after, _limits())["carried"]) == 1 def test_carrying_one_drifted_entry_does_not_license_a_second(self): - """Both are reported now — the new one, and the one that was tolerated.""" + """The carried entry buys no cover for the one written beside it.""" legacy = {"number": 1, "observation": "x" * 500} before = {"observations": [legacy]} after = {"observations": [{"number": 2, "observation": "y" * 500}, dict(legacy)]} - hits = el.changed_entries(before, after, _limits()) - assert {hit["key"] for hit in hits} == {"0", "1"} + + assert {hit["key"] for hit in el.changed_entries(before, after, _limits())} == {"0"} + assert {hit["key"] for hit in el.classify_entries(before, after, _limits())["carried"]} == {"1"} def test_a_dict_container_refuses_a_missing_field_too(self): limits = _limits(max_chars=200, field="value", container="key_learnings") diff --git a/src/aipass/memory/tests/test_watcher.py b/src/aipass/memory/tests/test_watcher.py index 3a46a5a49..107507b61 100644 --- a/src/aipass/memory/tests/test_watcher.py +++ b/src/aipass/memory/tests/test_watcher.py @@ -20,9 +20,12 @@ All tests use mocks -- no live filesystem watchers or infrastructure access. """ +import json import sys from unittest.mock import MagicMock +import pytest + # --------------------------------------------------------------------------- # Helpers: prepare the mock graph needed to import memory_watcher @@ -569,3 +572,97 @@ def test_normalize_no_changes_no_guard(self, monkeypatch): # file_key should NOT be in _recent_modifications from normalize (may be from rollover) mocks["check_single_file"].return_value = {"success": True, "should_rollover": False} assert file_key not in watcher._recent_modifications + + +class TestTheBranchPathWalkReadsNamesNotSpellings: + """The fourth of this tree's ``*_REGISTRY.json`` walks — detector's twin. + + Same caller-cwd walk, and the consequence is the one that reaches disk: a + folded match's "branches" become paths this watcher ROLLS OVER, which means + rewrites them. @seedgo's fleet discriminator named this site alongside + ``detector`` after ``registry_scope`` was cured; the filter now has one + implementation in ``handlers/repo_root.py`` for all four. + + A NOTE ON WHAT THIS NEARLY SHIPPED. ``_get_branch_paths`` rebinds the local + name ``repo_root`` to a Path, shadowing the module import of the same name, + so the first version of this fix read ``repo_root.exactly_named`` off a + Path. It did not fail the suite — nothing exercised the line. The module + imports ``exactly_named`` by name now, and this class is what would have + caught it. + """ + + @pytest.fixture + def caller_tree(self, tmp_path, monkeypatch): + from aipass.memory.apps.handlers.monitor import detector + from aipass.memory.apps.handlers.monitor import memory_watcher + + home = tmp_path / "aipass_home" + home.mkdir() + (home / "AIPASS_REGISTRY.json").write_text('{"branches":[]}', encoding="utf-8") + monkeypatch.setattr(memory_watcher, "_find_repo_root", lambda: home) + monkeypatch.setattr(detector, "_KNOWN_REGISTRIES_PATH", tmp_path / "known_registries.json") + + foreign = tmp_path / "foreign_project" + (foreign / "src" / "real").mkdir(parents=True) + (foreign / "src" / "bait").mkdir(parents=True) + (foreign / "FOREIGN_REGISTRY.json").write_text( + json.dumps({"branches": [{"name": "real", "path": "src/real", "status": "active"}]}), encoding="utf-8" + ) + (foreign / "flow_json_registry.json").write_text( + json.dumps({"branches": [{"name": "bait", "path": "src/bait", "status": "active"}]}), encoding="utf-8" + ) + monkeypatch.setenv("AIPASS_CALLER_CWD", str(foreign)) + return foreign + + def test_the_injected_world_really_widens(self, caller_tree, case_insensitive_filesystem): + """Positive control, before anything below is believed.""" + matched = sorted(path.name for path in caller_tree.glob("*_REGISTRY.json")) + + assert matched == ["FOREIGN_REGISTRY.json", "flow_json_registry.json"] + + def test_a_folded_registry_never_contributes_a_path_to_roll_over(self, caller_tree, case_insensitive_filesystem): + from aipass.memory.apps.handlers.monitor import memory_watcher + + names = {path.name for path in memory_watcher._get_branch_paths()} + + assert "real" in names, "the genuine branch was lost while excluding the impostor" + assert "bait" not in names + + +class TestTheTrinityMatchIsAFilenameNotAPattern: + """``local.json`` has no wildcard in it, so it was never a glob's job. + + On a folding filesystem ``trinity_dir.glob("local.json")`` also matches + ``Local.json``, and what this loop matches gets ROLLED OVER — rewritten. + A near-miss here is damage, not a miscount, which is why it moved to + ``repo_root.exists_exactly``. + """ + + def test_a_differently_cased_memory_file_is_not_checked(self, tmp_path, monkeypatch, case_insensitive_filesystem): + from aipass.memory.apps.handlers.monitor import memory_watcher + + branch = tmp_path / "src" / "aipass" / "impostor" + (branch / ".trinity").mkdir(parents=True) + (branch / ".trinity" / "Local.json").write_text("{}", encoding="utf-8") + monkeypatch.setattr(memory_watcher, "_get_branch_paths", lambda: [branch]) + # check_and_rollover is once-per-process by design; without this reset + # the SECOND test in this class reads "already checked" and asserts + # nothing. Found by the positive half failing, which is what it is for. + monkeypatch.setattr(memory_watcher, "_startup_check_done", False) + + assert memory_watcher.check_and_rollover()["files_checked"] == 0 + + def test_the_correctly_spelled_file_is_still_checked(self, tmp_path, monkeypatch, case_insensitive_filesystem): + """The guard must not blind the lane it protects.""" + from aipass.memory.apps.handlers.monitor import memory_watcher + + branch = tmp_path / "src" / "aipass" / "genuine" + (branch / ".trinity").mkdir(parents=True) + (branch / ".trinity" / "local.json").write_text("{}", encoding="utf-8") + monkeypatch.setattr(memory_watcher, "_get_branch_paths", lambda: [branch]) + # check_and_rollover is once-per-process by design; without this reset + # the SECOND test in this class reads "already checked" and asserts + # nothing. Found by the positive half failing, which is what it is for. + monkeypatch.setattr(memory_watcher, "_startup_check_done", False) + + assert memory_watcher.check_and_rollover()["files_checked"] == 1 diff --git a/src/aipass/prax/.seedgo/bypass.json b/src/aipass/prax/.seedgo/bypass.json index e993a60ba..a66796bcf 100644 --- a/src/aipass/prax/.seedgo/bypass.json +++ b/src/aipass/prax/.seedgo/bypass.json @@ -3,7 +3,7 @@ "version": "2.0.0", "created": "2026-03-07T22:43:24.315842", "description": "Standards bypass configuration for prax branch", - "last_updated": "2026-08-13T18:05:00.000000" + "last_updated": "2026-08-31T05:00:00.000000" }, "bypass": [ { @@ -48,6 +48,12 @@ "pattern": "logging.getLogger(", "reason": "Prax import chain \u2014 imported via discovery/watcher.py. Cannot import from aipass.prax.apps.modules.logger (circular dependency)." }, + { + "file": "apps/handlers/repo_root.py", + "standard": "log_visibility", + "pattern": "logging.getLogger(", + "reason": "Prax import chain \u2014 imported by config/load.py, which logger.py calls while CONSTRUCTING the system logger. Cannot import from aipass.prax.apps.modules.logger (circular dependency), and a diagnostic that needs the logger it is being emitted from is a second crash wearing a diagnostic's clothes (@memory, 2026-08-31). A test pins that this module imports nothing from aipass at all." + }, { "file": "apps/handlers/discovery/watcher.py", "standard": "handlers", diff --git a/src/aipass/prax/README.md b/src/aipass/prax/README.md index c3b1ceb69..e08ff606f 100644 --- a/src/aipass/prax/README.md +++ b/src/aipass/prax/README.md @@ -5,7 +5,7 @@ **Purpose:** System-wide logging, real-time monitoring, and dashboard infrastructure for AIPass. **Module:** `aipass.prax` **Version:** 2.4.0 -**Last Updated:** 2026-08-25 +**Last Updated:** 2026-08-30 --- @@ -334,6 +334,197 @@ This works from any branch. Prax detects the caller via stack introspection and Four levels are available — `debug()`, `info()`, `warning()`, `error()`. +**Ruling 2026-08-30 — the object form stays recommended.** @seedgo widened the +`imports` standard to accept three spellings and asked prax, as the owner of the +logging contract, which one to recommend. The answer is this one, and the reason +is that the import form was never the thing doing damage. + +The question arrived attached to a real measurement: one `@daemon` test performed +23 atomic writes into prax's live `prax_json/`, and the diagnosis was that +`from aipass.prax import logger` binds the logger *object*, so a conftest that +swaps `sys.modules["aipass.prax"]` cannot reach it. Measured here with an audit +hook before ruling, and the diagnosis does not survive: + +| | import | 1st call | 2nd call | +|---|---|---|---| +| `from aipass.prax import logger` | 0 | **26** | 0 | +| `from aipass import prax` → `prax.logger` | 0 | **26** | 0 | +| `from ...modules.logger import system_logger as logger` | 0 | **26** | 0 | + +**Importing prax writes nothing. The first `logger.info()` writes 26 times, and +the second writes nothing.** All three spellings are identical because the writes +come from the *call*, not the binding — the first call is what starts the file +watcher, fires `trigger.fire("startup")` and auto-creates the per-module JSON. +Changing the recommended import would have moved a number that does not depend +on it. + +The rebindability claim also does not hold against the mechanism it names. With +the swap landing *after* the caller's import — the shape an autouse fixture +produces — every form returns the real logger, the module form included: + +| swap timing | form 1 | form 2 (object) | form 3 (module) | +|---|---|---|---| +| `sys.modules` swap **after** import | REAL | REAL | **REAL** | +| `monkeypatch.setattr(prax, "logger", …)` | REAL | REAL | MOCK | +| `sys.modules` swap **before** import | **ModuleNotFoundError** | MOCK | MOCK | + +Ordering decides this, not binding style. Two things follow that are worth more +than the ruling itself. First, a `sys.modules` swap that lands after import fixes +nothing in *any* spelling, so branches carrying that fixture are not protected +today and would not have been protected by migrating. Second, the form the +standard labels canonical — importing through `apps.modules.logger` — is the only +one that **crashes** against a mocked `aipass.prax`, because the mock package has +no `apps` submodule. A branch that follows the top recommendation and mocks prax +gets `ModuleNotFoundError`, not a mock. + +Import cost is not load-bearing either: 131.0ms vs 130.7ms median over 5 samples +for the object and module forms — indistinguishable, because `prax/__init__.py` +eagerly imports `apps.modules.logger`, so every spelling pays the same package +init. Consistency is the only real tiebreak left, and one spelling across the +fleet is worth more than a rebindability property that does not work. + +**The actual defect is prax's, and it is located.** Under pytest, prax already +redirects log files — `get_system_logs_dir()` returns +`/tmp/aipass_test_logs/system` when `PYTEST_CURRENT_TEST` is set or a pytest +session is detected. `PRAX_JSON_DIR` never got the same treatment: it is a +module-level constant built from `__file__` with no pytest branch, so it resolves +to the real `prax_json/` in every suite in the fleet. + +``` +prax_json dir under pytest : src/aipass/prax/prax_json <- real state +system_logs dir under pytest: /tmp/aipass_test_logs/system <- already isolated +``` + +That asymmetry is the whole bug. It is one path resolution in one handler, it +fixes every branch at once, and it needs no conftest edits and no import +migration anywhere. Tracked in APLAN-0009; not built in the same pass that found +it, because it changes where prax's own suite reads and writes. + +### Mocking the logger — the contract + +@seedgo corrected their own dispatch within ten minutes of sending it, and the +corrected question is the better one: prax never published a mocking technique, +so five branches invented five, and all five miss. Measured by object identity +against a real consumer: + +| technique | reaches | +|---|---| +| `patch("aipass.prax.logger")` | REAL | +| `patch("…apps.modules.logger.system_logger")` | REAL | +| `setitem(sys.modules, "…apps.modules.logger")` | REAL | +| `setitem(sys.modules, "aipass.prax")` | REAL | +| `patch(".logger")` | **MOCK** | + +The cause is in this package's own `__init__.py`: it re-exports by binding +(`from …modules.logger import system_logger as logger`), so the object is copied +at the package boundary and copied again into each consumer's globals. Anything +patched at or above `aipass.prax` is upstream of a copy already taken. **The last +dot must be resolved at call time**, which only the consumer-module patch does. +prax's own conftest was one of the four that miss — this is prax's gap before it +is anyone else's. + +**Interim technique, correct today, one line per consuming module:** + +```python +patch("aipass..apps.handlers..logger") +``` + +**But do not build patch lists on it.** That is not the contract prax wants to +leave standing, because it asks 18 branches to maintain a per-module list for a +problem prax should solve once — and a branch that forgets a module gets silence, +not an error. + +**Ruling on the test seam: extend the mechanism that already exists, do not add a +new one.** prax already auto-detects pytest and redirects — no env var, no fixture, +no cooperation from the caller. It simply covers the wrong half. Measured with +`PYTEST_CURRENT_TEST` set, one `logger.info()`: + +``` + 4 writes -> /tmp/aipass_test_logs/ (log files — already redirected) +24 writes -> real src/aipass/prax/prax_json/ (JSON state — not redirected) +``` + +So the seam is built, proven and automatic for 4 of 28 writes. Extending it to +`PRAX_JSON_DIR` closes the remaining 24 and every branch's number at once, with +no patches, no import changes and nothing for a caller to remember. A new +`silence()` API would need adoption across 385 call sites; a new env var would +need a fixture nobody sets — @daemon offered exactly that mitigation to @memory +in good faith and it fixed 0%, because the thing it targeted was never the cause. +The explicit override for the other half already exists as `AIPASS_TEST_LOG_DIR`, +so the escape-hatch pattern is settled too. + +Callers need do nothing and should change nothing. Reported shares — @drone 7650, +@memory 1552, @daemon 1096, @backup 778 — are prax's to fix, not theirs. + +**Closed 2026-08-30 — `AIPASS_TEST_LOG_DIR` is the fleet contract.** +`json_handler.PRAX_JSON_DIR` now honours it, in @trigger's form +(`trigger/apps/handlers/json/json_handler.py`) rather than a sixth spelling +invented here. Measured on a real suite: + +| | before | after | +|---|---|---| +| one `logger.info()` under pytest | 24 writes into real `prax_json/` | **0** | +| prax's own suite, collection alone | 107 atomic renames + 535 mkdirs | **0** | + +**Resolution happens at call time, not import time**, and that is load-bearing. +The env-var branch alone was not enough: prax's own conftest sets the variable at +module scope and the constant *still* resolved to the live tree, because +something imports this module before the conftest runs. That is the same defect +as the unmockable logger one section up — a value captured at import cannot be +redirected by anything that runs later. A seam that depends on winning an import +race is not a seam. + +Precedence: an explicit `monkeypatch.setattr(mod, "PRAX_JSON_DIR", …)` wins (≈20 +tests in this suite rely on it), then `AIPASS_TEST_LOG_DIR`, then the real +directory. An **empty** env value is absence, not a redirect — `Path("") / "prax"` +is relative and would scatter state wherever the process happens to stand. + +**Corrected 2026-08-30 — do not detect the override against a captured value.** +prax's first cut compared the attribute by *identity* against the import-time +value. @daemon adopted that from prax's own contract mail and 9 of their pins went +green alone and red in the full suite: a test calling `importlib.reload` while a +monkeypatch is live has its teardown write the **pre-reload** Path back onto the +**post-reload** module, so the attribute is no longer the object the module holds +and every later call reads it as a deliberate override — the redirect dies +silently for the rest of the session, in a branch that looks adopted. **All 18 +branches use `importlib.reload` somewhere**, so this is everyone's problem; +prax was shielded only by a conftest that drops the module from `sys.modules`. + +@daemon's fix — compare by value — rescues their ordering but not the one that +made call-time resolution necessary: import first, env set afterwards. There the +written-back value is the **real** directory while the post-reload default is the +**redirect**, so the two differ and a value comparison *also* reads "explicitly +patched". Reproduced against prax's own module: + +``` +IDENTITY: False EQUAL: False +before = /tmp/prax_rl_b/prax/prax_json +after = /home/…/src/aipass/prax/prax_json *** redirect silently died *** +``` + +The fix is to compare against **both fixed points** and hold nothing stale — an +override counts only when it differs from the real directory *and* from the +current redirect target: + +```python +default = _resolve_prax_json_dir(os.environ.get("AIPASS_TEST_LOG_DIR"), _PRAX_ROOT) +real = _resolve_prax_json_dir(None, _PRAX_ROOT) +if PRAX_JSON_DIR != real and PRAX_JSON_DIR != default: + return PRAX_JSON_DIR +return default +``` + +Cost, stated rather than hidden: a test that patches this to the real directory, +or to exactly the redirect target, is indistinguishable from one that never +patched — but both resolve to the same path anyway, so no answer changes. Both +reload orderings verified to survive. + +Each branch adopts the same variable in its **own** `json_handler`; prax cannot +redirect another branch's state directory. And the per-module logger patch stays +**opt-in per module, never blanket autouse** — @daemon proved a blanket mock +silenced their refused-and-named `caplog` pin, and a suite that cannot show its +refusals are loud has traded evidence for a number. + ### Log levels `debug()` is silent by default. Nothing it logs reaches a file until the level is @@ -464,7 +655,7 @@ prax/ │ └── watcher/ # Background system watchers ├── prax_json/ # Auto-created per-module config/data/log files ├── templates/ # Dashboard template schema (DASHBOARD.template.json) -└── tests/ # 1372 tests across 36 files +└── tests/ # 1380 tests across 36 files ``` ### Design Pattern @@ -493,7 +684,7 @@ drone @prax monitor run ## Tests -1372 tests across 36 files (1371 pass, 1 skipped), covering all major components: +1380 tests across 36 files (1379 pass, 1 skipped), covering all major components: | Test File | Tests | Coverage | |-----------|-------|----------| @@ -509,7 +700,7 @@ drone @prax monitor run | test_logger_module.py | 46 | Logger init, routing, lifecycle, NullLogger fallback | | test_event_queue.py | 49 | Thread-safe event buffering, scope suppression | | test_monitoring_filters.py | 39 | Event filtering rules | -| test_commons_feed.py | 28 | Commons live feed, cursors, room filtering | +| test_commons_feed.py | 27 | Commons live feed, cursors, room filtering, full-body rendering | | test_instance_lock.py | 28 | Single-instance locking, stale reclaim | | test_rate_tracker.py | 34 | Rate tracking, thresholds, persistence (incl. rate history), suppression | | test_discovery.py | 25 | Module scanning | @@ -561,7 +752,7 @@ drone @prax monitor run --- -*Last Updated: 2026-08-25* +*Last Updated: 2026-08-30* --- [← Back to AIPass](../../../README.md) diff --git a/src/aipass/prax/apps/handlers/__init__.py b/src/aipass/prax/apps/handlers/__init__.py index bab789874..cc1b1298d 100755 --- a/src/aipass/prax/apps/handlers/__init__.py +++ b/src/aipass/prax/apps/handlers/__init__.py @@ -1,6 +1,7 @@ """Prax handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "aipass.prax" @@ -16,27 +17,58 @@ def _find_real_caller(): - Frozen modules Returns tuple: (file_path, import_line) or (None, None) + + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on the + Windows CI gate 2026-08-31 by @spawn, reproduced here: inspect.stack() needs + a READABLE WORKING DIRECTORY, and it needs one before any of this function's + own code runs. It builds a FrameInfo per frame, which reaches getsourcefile() + -> getmodule() -> os.path.realpath(); ntpath.realpath calls os.getcwd() + unconditionally on its first lines, before it even checks whether the path is + absolute, and that call site inside getmodule is not in a try. On POSIX the + equivalent raise happens earlier, inside getabsfile(), where inspect catches + it — so every Linux pin here was green for a reason unrelated to correctness. + + A frame's co_filename is already a string in memory. Reading it touches + nothing, so the walk itself cannot need a working directory. """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) + # Path.resolve() reaches the same ntpath.realpath, so this is guarded too. + # __file__ is already absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted (drone's dead-cwd routing case) dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back + continue - for frame_info in stack: - filename = frame_info.filename + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import of every prax consumer. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename # Skip this file - if this_file in str(Path(filename).resolve()): - continue - - # Skip Python internals - if filename.startswith("<") or "importlib" in filename: + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - # Found a real file - try to get the import line - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + # linecache is what inspect used for code_context. Called directly it + # reads one named file and returns "" rather than raising. + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -70,14 +102,17 @@ def _guard_branch_access(): print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) if caller_file is None: - # Can't determine caller from real files - # Check if we're being run from command line (external) - # by looking at the raw stack for or - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return # Allow command-line Python through - return # Allow if truly can't determine + # An undeterminable caller is ALLOWED — a command line, an embedded + # interpreter, a frozen loader. The guard convicts on evidence; not + # having any is not evidence. + # + # A second inspect.stack() used to sit here, scanning for or + # — and it was DEAD: both the match and the fall-through + # returned None with no side effect, so the whole call could not change + # the answer. It is deleted rather than guarded, because a call that + # needs a working directory to compute a value nobody reads is pure + # exposure. @devpulse's rule from the fleet sweep, and it applied here. + return # Check if caller is from our branch # MY_BRANCH is "aipass.prax" (dotted), but filesystem uses "/aipass/prax/" diff --git a/src/aipass/prax/apps/handlers/config/load.py b/src/aipass/prax/apps/handlers/config/load.py index ece815ac6..947b1ecb0 100755 --- a/src/aipass/prax/apps/handlers/config/load.py +++ b/src/aipass/prax/apps/handlers/config/load.py @@ -27,7 +27,6 @@ max_lines = system_logs['max_lines'] """ -import inspect import json import logging import os @@ -37,6 +36,8 @@ from typing import Dict, Any, Optional from aipass.prax.apps.handlers.json import json_handler +from aipass.prax.apps.handlers.repo_root import find_repo_root +from aipass.prax.apps.handlers.repo_root import resolved_file logger = logging.getLogger(__name__) @@ -47,18 +48,22 @@ MODULE_NAME = "load" # Package path resolution (no hardcoded paths) -PRAX_ROOT = Path(__file__).resolve().parents[3] # config/load.py → handlers/ → apps/ → prax/ +PRAX_ROOT = resolved_file(Path(__file__)).parents[3] # config/load.py → handlers/ → apps/ → prax/ ECOSYSTEM_ROOT = PRAX_ROOT.parent # prax/ → aipass/ (contains all sibling modules) PRAX_JSON_DIR = PRAX_ROOT / "prax_json" def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up from this file to find the repo root, never reading the cwd. + + Delegates to ``handlers/repo_root.py``. Prax carried eight private copies of + this walk, every one ending ``return Path.cwd()`` — @memory reported the + consequence with a traceback on 2026-08-31: the walk runs at IMPORT time in + most of the fleet, so a deleted working directory crashed the import, and a + registry-less checkout (every clean CI clone) resolved against wherever the + shell stood. + """ + return find_repo_root(Path(__file__)) def _is_pytest_session() -> bool: @@ -113,6 +118,75 @@ def _warn_routing(module_name: str, destination: object) -> None: ) +#: A frame whose co_filename is bracketed has no file behind it — ``, +#: `` from ``python -c`` or ``exec``, ``. +_PSEUDO_FILENAME = ("<", ">") + + +def _module_name_from_filename(filename: str) -> str: + """Turn a frame's ``co_filename`` into a module name, or admit there is none. + + A PSEUDO-FRAME HAS NO MODULE, AND THE NAME GOES ON TO BE A DIRECTORY. Found + by the Windows CI runner 2026-08-31 (run 33431848734): ``Path("").stem`` + is ``""``, and :func:`get_module_logs_dir` mkdirs what it is given. + Linux creates a directory literally called ```` — ten of them were + sitting in /tmp from prax's own test probes before anyone looked. Windows + reserves ``<`` and ``>`` in a path component, so the same line raises there, + from the logging path, which is the worst place for an unhandled raise. + + The defect predates the sys._getframe cure: ``inspect.stack()[1].filename`` + read the same co_filename and had the same hole. The dead-cwd pins are + simply the first callers that ever reached this from an interpreter + started with ``-c`` or fed on stdin. + + Guessing a name from ``<...>`` is worse than having none: it invents an + attribution AND builds a directory for it. So a pseudo-frame gets the same + answer as no caller at all. + + Args: + filename: A frame's ``co_filename``. + + Returns: + The module name, or ``"unknown"`` when the frame has no file behind it. + """ + if filename.startswith(_PSEUDO_FILENAME[0]) and filename.endswith(_PSEUDO_FILENAME[1]): + return "unknown" + name = Path(filename).stem + return name or "unknown" + + +def _get_caller_module_name(depth: int = 1) -> str: + """Name the module `depth` frames above this one, without touching the disk. + + sys._getframe, never inspect.stack(). This question needs ONE filename; + inspect.stack() builds a FrameInfo for every frame on the stack to answer + it, and that path runs getsourcefile -> getmodule -> + `modulesbyfile[os.path.realpath(f)]`, where the realpath is not inside a + try. On Windows ntpath.realpath calls os.getcwd() on its first lines, + before it checks whether the path is even absolute — so the old + `inspect.stack()[1]` here NEEDED A READABLE WORKING DIRECTORY, unguarded, + in the primary local-log-directory resolver, on the logging path. A box + with a disconnected share or a deleted cwd raised from here at exactly the + moment logging was the only thing that could say what went wrong. + + Reported by @memory and @trigger independently on 2026-08-31, each running + prax inside their own denied world. A frame's co_filename is already a + string in memory: reading it touches nothing, gives the same answer, and is + cheaper on a hot path besides. + + Args: + depth: Frames above THIS function. 1 is the direct caller. + + Returns: + The caller's module name, or "unknown" when the stack is too shallow. + """ + try: + frame = sys._getframe(depth) + except ValueError: + return "unknown" + return _module_name_from_filename(frame.f_code.co_filename) + + def get_module_logs_dir(module_name: Optional[str] = None) -> Path: """Get the branch-root logs directory for a module. @@ -136,8 +210,7 @@ def get_module_logs_dir(module_name: Optional[str] = None) -> Path: """ # Auto-detect caller module name when not provided if module_name is None: - frame = inspect.stack()[1] - module_name = Path(frame.filename).stem + module_name = _get_caller_module_name(depth=2) test_log_dir = os.environ.get("AIPASS_TEST_LOG_DIR") if test_log_dir: diff --git a/src/aipass/prax/apps/handlers/dashboard/agent_status_writer.py b/src/aipass/prax/apps/handlers/dashboard/agent_status_writer.py index 2a86d209f..d44057a45 100644 --- a/src/aipass/prax/apps/handlers/dashboard/agent_status_writer.py +++ b/src/aipass/prax/apps/handlers/dashboard/agent_status_writer.py @@ -31,6 +31,7 @@ logger = get_direct_logger() from aipass.prax.apps.handlers.json import json_handler +from aipass.prax.apps.handlers.repo_root import find_repo_root # ============================================================================= @@ -39,12 +40,16 @@ def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up from this file to find the repo root, never reading the cwd. + + Delegates to ``handlers/repo_root.py``. Prax carried eight private copies of + this walk, every one ending ``return Path.cwd()`` — @memory reported the + consequence with a traceback on 2026-08-31: the walk runs at IMPORT time in + most of the fleet, so a deleted working directory crashed the import, and a + registry-less checkout (every clean CI clone) resolved against wherever the + shell stood. + """ + return find_repo_root(Path(__file__)) def _get_aipass_registry() -> Path: diff --git a/src/aipass/prax/apps/handlers/dashboard/operations.py b/src/aipass/prax/apps/handlers/dashboard/operations.py index 669e276ba..791de84e6 100644 --- a/src/aipass/prax/apps/handlers/dashboard/operations.py +++ b/src/aipass/prax/apps/handlers/dashboard/operations.py @@ -23,9 +23,10 @@ logger = get_direct_logger() from aipass.prax.apps.handlers.json import json_handler # noqa: E402 +from aipass.prax.apps.handlers.repo_root import resolved_file # Resolve prax root from this file's location -_PRAX_ROOT = Path(__file__).resolve().parents[3] # .../prax/ +_PRAX_ROOT = resolved_file(Path(__file__)).parents[3] # .../prax/ def get_dashboard_path(branch_path: Path) -> Path: diff --git a/src/aipass/prax/apps/handlers/dashboard/refresh.py b/src/aipass/prax/apps/handlers/dashboard/refresh.py index a2415517a..812b5148c 100644 --- a/src/aipass/prax/apps/handlers/dashboard/refresh.py +++ b/src/aipass/prax/apps/handlers/dashboard/refresh.py @@ -31,18 +31,23 @@ from aipass.prax.apps.handlers.json import json_handler # noqa: E402 from .template_pusher import DEPRECATED_SECTIONS # noqa: E402 +from aipass.prax.apps.handlers.repo_root import find_repo_root # Sections managed by the refresh path — everything else is write-through only REFRESH_MANAGED_SECTIONS = {"ai_mail", "flow", "memory"} def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up from this file to find the repo root, never reading the cwd. + + Delegates to ``handlers/repo_root.py``. Prax carried eight private copies of + this walk, every one ending ``return Path.cwd()`` — @memory reported the + consequence with a traceback on 2026-08-31: the walk runs at IMPORT time in + most of the fleet, so a deleted working directory crashed the import, and a + registry-less checkout (every clean CI clone) resolved against wherever the + shell stood. + """ + return find_repo_root(Path(__file__)) # Infrastructure diff --git a/src/aipass/prax/apps/handlers/dashboard/status.py b/src/aipass/prax/apps/handlers/dashboard/status.py index 972dc8a43..8fccaab8f 100644 --- a/src/aipass/prax/apps/handlers/dashboard/status.py +++ b/src/aipass/prax/apps/handlers/dashboard/status.py @@ -19,17 +19,22 @@ from aipass.prax.apps.modules.logger import get_direct_logger from aipass.prax.apps.handlers.json import json_handler +from aipass.prax.apps.handlers.repo_root import find_repo_root logger = get_direct_logger() def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up from this file to find the repo root, never reading the cwd. + + Delegates to ``handlers/repo_root.py``. Prax carried eight private copies of + this walk, every one ending ``return Path.cwd()`` — @memory reported the + consequence with a traceback on 2026-08-31: the walk runs at IMPORT time in + most of the fleet, so a deleted working directory crashed the import, and a + registry-less checkout (every clean CI clone) resolved against wherever the + shell stood. + """ + return find_repo_root(Path(__file__)) AIPASS_REGISTRY = _find_repo_root() / "AIPASS_REGISTRY.json" diff --git a/src/aipass/prax/apps/handlers/dashboard/template_differ.py b/src/aipass/prax/apps/handlers/dashboard/template_differ.py index 03f9699a7..541f148ee 100644 --- a/src/aipass/prax/apps/handlers/dashboard/template_differ.py +++ b/src/aipass/prax/apps/handlers/dashboard/template_differ.py @@ -29,6 +29,8 @@ from aipass.prax.apps.modules.logger import get_direct_logger from aipass.prax.apps.handlers.json import json_handler +from aipass.prax.apps.handlers.repo_root import find_repo_root +from aipass.prax.apps.handlers.repo_root import resolved_file logger = get_direct_logger() @@ -36,16 +38,20 @@ # PATH RESOLUTION # ============================================================================= -_PRAX_ROOT = Path(__file__).resolve().parents[3] # .../prax/ +_PRAX_ROOT = resolved_file(Path(__file__)).parents[3] # .../prax/ def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up from this file to find the repo root, never reading the cwd. + + Delegates to ``handlers/repo_root.py``. Prax carried eight private copies of + this walk, every one ending ``return Path.cwd()`` — @memory reported the + consequence with a traceback on 2026-08-31: the walk runs at IMPORT time in + most of the fleet, so a deleted working directory crashed the import, and a + registry-less checkout (every clean CI clone) resolved against wherever the + shell stood. + """ + return find_repo_root(Path(__file__)) # ============================================================================= diff --git a/src/aipass/prax/apps/handlers/dashboard/template_pusher.py b/src/aipass/prax/apps/handlers/dashboard/template_pusher.py index 957e43429..d91b273ec 100644 --- a/src/aipass/prax/apps/handlers/dashboard/template_pusher.py +++ b/src/aipass/prax/apps/handlers/dashboard/template_pusher.py @@ -37,21 +37,27 @@ from aipass.prax.apps.handlers.json import json_handler # noqa: E402 from .status import calculate_quick_status, merge_quick_status # noqa: E402 +from aipass.prax.apps.handlers.repo_root import find_repo_root +from aipass.prax.apps.handlers.repo_root import resolved_file # ============================================================================= # PATH RESOLUTION # ============================================================================= -_PRAX_ROOT = Path(__file__).resolve().parents[3] # .../prax/ +_PRAX_ROOT = resolved_file(Path(__file__)).parents[3] # .../prax/ def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up from this file to find the repo root, never reading the cwd. + + Delegates to ``handlers/repo_root.py``. Prax carried eight private copies of + this walk, every one ending ``return Path.cwd()`` — @memory reported the + consequence with a traceback on 2026-08-31: the walk runs at IMPORT time in + most of the fleet, so a deleted working directory crashed the import, and a + registry-less checkout (every clean CI clone) resolved against wherever the + shell stood. + """ + return find_repo_root(Path(__file__)) # ============================================================================= diff --git a/src/aipass/prax/apps/handlers/discovery/watcher.py b/src/aipass/prax/apps/handlers/discovery/watcher.py index e03dc082a..abcc296d4 100755 --- a/src/aipass/prax/apps/handlers/discovery/watcher.py +++ b/src/aipass/prax/apps/handlers/discovery/watcher.py @@ -40,12 +40,24 @@ from aipass.prax.apps.handlers.discovery.filtering import should_ignore_path from aipass.prax.apps.handlers.json import json_handler -# Trigger integration - graceful fallback if trigger not available +# Trigger integration - graceful fallback if trigger not available. +# +# The except is (ImportError, OSError), matching modules/logger.py's own trigger +# import, and the second half is not decoration. MEASURED 2026-08-31: @trigger's +# handlers/__init__.py guard resolves a frame filename at import time, and in a +# process with no readable working directory that raises FileNotFoundError — an +# OSError, never an ImportError. So this clause, which exists precisely to say +# "we can live without trigger", let a failure in the dependency we can live +# without kill the import of every prax consumer, including the fleet's logger. +# +# The rule that fixed it: an optional dependency's fallback must be at least as +# wide as the failures its import can produce. A peer branch being broken is +# allowed; us dying of it is not. try: from aipass.trigger.apps.modules.core import trigger _HAS_TRIGGER = True -except ImportError as e: +except (ImportError, OSError) as e: logger.info(f"[watcher] trigger module not available, falling back: {e}") trigger = None # type: ignore[assignment] _HAS_TRIGGER = False diff --git a/src/aipass/prax/apps/handlers/json/json_handler.py b/src/aipass/prax/apps/handlers/json/json_handler.py index 7fcf0e388..466fed0ef 100755 --- a/src/aipass/prax/apps/handlers/json/json_handler.py +++ b/src/aipass/prax/apps/handlers/json/json_handler.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: json_handler.py # Description: Auto-Creating & Self-Healing JSON System -# Version: 1.1.0 +# Version: 1.4.0 # Created: 2025-11-15 -# Modified: 2026-08-18 +# Modified: 2026-08-31 # ============================================= """ @@ -17,19 +17,138 @@ import logging import os import tempfile +import sys import time from pathlib import Path from datetime import datetime from typing import Dict, Any, Optional -import inspect +from aipass.prax.apps.handlers.repo_root import resolved_file logger = logging.getLogger(__name__) # Resolve paths relative to this file (no hardcoded paths) -_HANDLER_DIR = Path(__file__).resolve().parent # .../handlers/json/ +_HANDLER_DIR = resolved_file(Path(__file__)).parent # .../handlers/json/ _HANDLERS_DIR = _HANDLER_DIR.parent # .../handlers/ _PRAX_ROOT = _HANDLERS_DIR.parent.parent # .../prax/ -PRAX_JSON_DIR = _PRAX_ROOT / "prax_json" + + +def _resolve_prax_json_dir(test_log_dir: Optional[str], prax_root: Path) -> Path: + """Resolve the prax_json directory, honouring the fleet's test-redirect seam. + + prax redirected its log FILES under pytest for a long time + (``config/load.py::get_system_logs_dir``) and this constant never got the + same branch, so one ``logger.info()`` under pytest wrote 4 redirected files + and 24 real ones into the live ``prax_json/``. Every branch's suite paid it. + + ``AIPASS_TEST_LOG_DIR`` is the fleet contract, in @trigger's form + (``trigger/apps/handlers/json/json_handler.py``) rather than a spelling + invented here — five techniques already existed and a sixth would be the + problem, not the fix. + + An EMPTY value is absence, not a redirect: ``Path("") / "prax"`` is a + relative path that would scatter state wherever the process happens to + stand, and an unset-looking env var must not do that. + + Args: + test_log_dir: the raw ``AIPASS_TEST_LOG_DIR`` value, or None + prax_root: the real prax branch root + + Returns: + The directory prax JSON state should be written to. + """ + if test_log_dir: + return Path(test_log_dir) / "prax" / "prax_json" + return prax_root / "prax_json" + + +# Seeded with None DELIBERATELY: the anchor must be the real directory and never +# a redirect. @drone wrote this precondition down when they adopted the contract +# ("drone survives both orderings because _IMPORT_TIME_JSON_DIR is env-INDEPENDENT +# ... and that precondition is load-bearing") and prax shipped the contract while +# violating it. @daemon named the general rule: a reference that is itself derived +# from the thing you are detecting cannot detect it. When AIPASS_TEST_LOG_DIR is +# already exported at import — every repo-root pytest run, where some other +# branch's conftest sets it first — an env-derived anchor makes PRAX_JSON_DIR a +# redirect, and the next call that sees a DIFFERENT redirect reads the stale one +# as an explicit patch and returns it for the rest of the process. +_IMPORT_TIME_JSON_DIR = _resolve_prax_json_dir(None, _PRAX_ROOT) + +# Kept as a module attribute because ~20 tests across this suite redirect state +# with ``monkeypatch.setattr(mod, "PRAX_JSON_DIR", tmp_path)``. That remains the +# supported override and it still wins — see _current_json_dir(). +PRAX_JSON_DIR = _IMPORT_TIME_JSON_DIR + + +def _current_json_dir() -> Path: + """Resolve the state directory at CALL time, not import time. + + Import-time resolution was not enough, and the reason is the same one that + made the logger unmockable: a value captured when the module loads cannot be + redirected by anything that runs afterwards. Measured here — prax's own + conftest sets ``AIPASS_TEST_LOG_DIR`` at module scope and the constant STILL + resolved to the live tree, because something imports this module before the + conftest runs. A seam that depends on winning an import race is not a seam. + + Precedence, in order: + 1. An explicit ``PRAX_JSON_DIR`` override (a test patched the attribute), + recognised as one only when it differs from BOTH the real directory and + the current redirect target. + 2. ``AIPASS_TEST_LOG_DIR``, re-read every call so it works whenever it is set. + 3. The real ``prax_json/``. + + Why not compare against a captured import-time value — either by identity or + by value. @daemon adopted the identity form from prax's own contract mail and + 9 of their pins went green alone and red in the full suite: a test that calls + ``importlib.reload`` while a monkeypatch is live has its teardown write the + PRE-reload Path back onto the POST-reload module, so the attribute is no + longer the object the module now holds and every later call reads it as an + explicit override. Reproduced here against this module, and prax is not + immune — only shielded by a conftest that drops it from ``sys.modules``. + + @daemon's fix (compare by value) rescues their ordering but not the one that + made call-time resolution necessary in the first place: import first, env set + afterwards. There the written-back value is the REAL directory while the + post-reload default is the REDIRECT, so the two differ and a value comparison + also reads "explicitly patched" — and the writes go back to the live tree, + silently, for the rest of the session. + + Both fixed points are only genuinely fixed if the anchor this module seeds + ``PRAX_JSON_DIR`` from is env-INDEPENDENT — see ``_IMPORT_TIME_JSON_DIR``. + While it was env-derived the seed itself could BE a redirect, and then case 1 + fired on a stale redirect nobody patched. Measured by @devpulse on the CI + train: two prax pins that pass alone and fail in a repo-root batch, resolving + to a previous run's mkdtemp. + + A SURVIVING MUTANT, reported rather than swept: dropping ``and PRAX_JSON_DIR + != default`` kills no test on POSIX, and it cannot — when the attribute EQUALS + default, returning it and returning ``default`` yield the same path. It is + kept because it states the two-fixed-point rule this contract published and + @drone/@daemon implement in the same shape. + + CORRECTED 2026-08-31 by @drone, who reproduced the mutant and then found the + asymmetry: prax called the clause "value-neutral by construction", and that + holds only on POSIX. ``PurePath.__eq__`` is case-FOLDED on Windows, so + ``PureWindowsPath("C:/Temp/Prax_Json") == PureWindowsPath("c:/temp/prax_json")`` + is True while ``str()`` of the two differs. There, returning the attribute + instead of ``default`` yields the same FILE under a different STRING — + invisible to a write, visible in a log line or in any assertion comparing + paths as text. The clause is the cheaper side on merit, not merely the tidier + one. + + Comparing against both fixed points has no such stale reference. The cost is + one lost distinction, stated rather than hidden: a test that patches this to + the real directory, or to exactly the redirect target, is indistinguishable + from one that never patched. Both resolve to the same path either way, so the + answer is unchanged — which is why this is cheaper than @daemon's loss and far + cheaper than a seam that dies to a reload. + """ + default = _resolve_prax_json_dir(os.environ.get("AIPASS_TEST_LOG_DIR"), _PRAX_ROOT) + real = _resolve_prax_json_dir(None, _PRAX_ROOT) + if PRAX_JSON_DIR != real and PRAX_JSON_DIR != default: + return PRAX_JSON_DIR + return default + + JSON_TEMPLATES_DIR = _HANDLERS_DIR / "json_templates" @@ -71,24 +190,43 @@ def _get_caller_module_name() -> str: Returns: Module name (e.g., "imports_standard" from imports_standard.py) """ + # sys._getframe, never inspect.stack(). The old form was GUARDED and LOGGED + # and still wrong: inspect.stack() reaches an unguarded os.path.realpath + # inside getmodule, and on Windows ntpath.realpath calls os.getcwd() before + # it checks anything — so on a box with no readable working directory the + # except below caught a FileNotFoundError and recorded every operation as + # "unknown". @trigger saw it twice in a single import chain and @memory + # reported it the same morning. Degraded is not cured: the operations log + # stops naming anyone exactly when the machine is in the state that makes + # the log worth reading. + # + # Frame skipping is unchanged: [0] is this function, [1] is log_operation, + # [2] is the caller we want. _getframe raises ValueError when the stack is + # shallower than that, which is the honest "no caller" case. try: - stack = inspect.stack() - # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller - if len(stack) > 2: - caller_frame = stack[2] - caller_path = Path(caller_frame.filename) - module_name = caller_path.stem - - # Validate module name - if module_name and not module_name.startswith("_"): - return module_name - - # Fallback + caller_frame = sys._getframe(2) + except ValueError: return "unknown" - except Exception as e: - logger.warning("json_handler: failed to detect caller module name: %s", e) + + # A pseudo-frame has no module behind it. ``, `` from + # python -c or exec, `` — Path("").stem + # is "", and an operations log that attributes work to that is + # asserting something false about who did it. Same read as config/load.py's + # _module_name_from_filename, which is where the full account lives; it is + # not imported because this module is reached from inside logger + # construction and must stay independent of it. + filename = caller_frame.f_code.co_filename + if filename.startswith("<") and filename.endswith(">"): return "unknown" + module_name = Path(filename).stem + + # Validate module name + if module_name and not module_name.startswith("_"): + return module_name + + return "unknown" + def load_template(json_type: str, module_name: str) -> Any: """Load JSON template from template file""" @@ -135,12 +273,12 @@ def validate_json_structure(data: Any, json_type: str) -> bool: def get_json_path(module_name: str, json_type: str) -> Path: """Get path for module JSON file""" filename = f"{module_name}_{json_type}.json" - return PRAX_JSON_DIR / filename + return _current_json_dir() / filename def ensure_json_exists(module_name: str, json_type: str) -> bool: """Ensure JSON file exists, create from template if missing""" - PRAX_JSON_DIR.mkdir(parents=True, exist_ok=True) + _current_json_dir().mkdir(parents=True, exist_ok=True) json_path = get_json_path(module_name, json_type) diff --git a/src/aipass/prax/apps/handlers/logging/introspection.py b/src/aipass/prax/apps/handlers/logging/introspection.py index 6abf8116b..20404159b 100755 --- a/src/aipass/prax/apps/handlers/logging/introspection.py +++ b/src/aipass/prax/apps/handlers/logging/introspection.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: introspection.py # Description: Stack Introspection -# Version: 1.0.0 +# Version: 1.1.0 # Created: 2025-11-10 -# Modified: 2026-03-09 +# Modified: 2026-08-31 # ============================================= """ @@ -18,6 +18,7 @@ from typing import Optional from aipass.prax.apps.handlers.json import json_handler +from aipass.prax.apps.handlers.repo_root import resolved_file logger = logging.getLogger(__name__) @@ -111,11 +112,43 @@ def get_caller_info() -> tuple: return (module_name, caller_path, branch) -_AIPASS_PKG_ROOT = Path(__file__).resolve().parents[4] # logging/ → handlers/ → apps/ → prax/ → aipass/ +_AIPASS_PKG_ROOT = resolved_file(Path(__file__)).parents[4] # logging/ → handlers/ → apps/ → prax/ → aipass/ _SRC_ROOT = _AIPASS_PKG_ROOT.parent # aipass/ → src/ (contains branches outside aipass namespace) _REPO_ROOT = _SRC_ROOT.parent # src/ → AIPass repo root +def _resolve_caller_path(module_path: str) -> Optional[Path]: + """Resolve a caller's path without ever requiring a working directory. + + The stack hands back whatever ``__file__`` says, and that is not always an + absolute path to a real file: a heredoc or an embedded interpreter reports + ````, and a module imported via a relative entry reports a relative + name. ``Path.resolve()`` on either of those reads the process working + directory — so in a process whose cwd has been deleted this raised + FileNotFoundError while prax was building the logger, one frame EARLIER than + the config/load.py crash @memory reported on 2026-08-31 (their caller was a + real absolute file, so their traceback walked past this line). + + Returns: + The resolved path, or None when the caller cannot be located — which is + an answer, not a failure. Branch detection is a routing hint; not knowing + where the caller lives must never take down the caller's import. + """ + # ONE guard, not two. An explicit ``startswith("<")`` check was written here + # first and then removed: a pseudo-filename is not absolute, so is_absolute() + # already refuses it, and a mutation dropping the "<" check killed no test — + # correctly, because it could not. Two guards where one decides is a reader + # believing both are load-bearing. + candidate = Path(module_path) if module_path else None + if candidate is None or not candidate.is_absolute(): + return None + try: + return candidate.resolve() + except OSError as exc: + logger.info("Caller path %s could not be resolved: %s", module_path, exc) + return None + + def detect_branch_from_path(module_path: str) -> Optional[str]: """Detect branch name from module file path @@ -132,11 +165,10 @@ def detect_branch_from_path(module_path: str) -> Optional[str]: Returns: Module/branch name (e.g., "flow") or None """ - if not module_path: + path = _resolve_caller_path(module_path) + if path is None: return None - path = Path(module_path).resolve() - # Primary: src/aipass/{branch}/... try: relative = path.relative_to(_AIPASS_PKG_ROOT) @@ -178,11 +210,10 @@ def detect_external_project(module_path: str) -> Optional[tuple]: Returns: (project_name, project_root) or None if path is inside AIPass or unresolvable """ - if not module_path: + path = _resolve_caller_path(module_path) + if path is None: return None - path = Path(module_path).resolve() - # If the path is inside the AIPass repo, it's not external try: path.relative_to(_REPO_ROOT) diff --git a/src/aipass/prax/apps/handlers/logging/log_watchdog.py b/src/aipass/prax/apps/handlers/logging/log_watchdog.py index e4cf1af33..e8028b82b 100644 --- a/src/aipass/prax/apps/handlers/logging/log_watchdog.py +++ b/src/aipass/prax/apps/handlers/logging/log_watchdog.py @@ -34,6 +34,7 @@ from typing import Any, Dict, List, Tuple from aipass.prax.apps.handlers.json import json_handler +from aipass.prax.apps.handlers.repo_root import find_repo_root logger = logging.getLogger(__name__) @@ -44,12 +45,16 @@ def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() + """Walk up from this file to find the repo root, never reading the cwd. + + Delegates to ``handlers/repo_root.py``. Prax carried eight private copies of + this walk, every one ending ``return Path.cwd()`` — @memory reported the + consequence with a traceback on 2026-08-31: the walk runs at IMPORT time in + most of the fleet, so a deleted working directory crashed the import, and a + registry-less checkout (every clean CI clone) resolved against wherever the + shell stood. + """ + return find_repo_root(Path(__file__)) _system_logs_dir_cache: Path | None = None diff --git a/src/aipass/prax/apps/handlers/monitoring/branch_detector.py b/src/aipass/prax/apps/handlers/monitoring/branch_detector.py index f5dc9aaa6..d955d8ac3 100644 --- a/src/aipass/prax/apps/handlers/monitoring/branch_detector.py +++ b/src/aipass/prax/apps/handlers/monitoring/branch_detector.py @@ -23,10 +23,32 @@ from aipass.prax.apps.modules.logger import get_direct_logger from aipass.prax.apps.handlers.json import json_handler +from aipass.prax.apps.handlers.repo_root import find_repo_root logger = get_direct_logger() +def _exact_case_registries(project_dir: Path) -> list: + """Glob for project registries, then re-check the case EXACTLY. + + ``Path.glob`` is case-INSENSITIVE on Windows, so ``*_REGISTRY.json`` also + matches ``project_registry.json`` there and a file that is not the trust + anchor gets read as one. @memory found the same exposure in both their + registry walks on 2026-08-31 and closed it with an exact-case post-filter; + this is that filter, in prax's one exposed walk. + + The post-filter is the fix rather than a different pattern, because the + case-folding lives in the OS matcher and no pattern spelling escapes it. + + Args: + project_dir: A directory under ``projects/`` to look in. + + Returns: + Sorted registry files whose names really do end in ``_REGISTRY.json``. + """ + return sorted(path for path in project_dir.glob("*_REGISTRY.json") if path.name.endswith("_REGISTRY.json")) + + class BranchDetector: """ Detects branch ownership for files, logs, and modules. @@ -50,15 +72,14 @@ def __init__(self): json_handler.log_operation("branch_detected", {"known_branches": len(self.known_branches)}) def _find_repo_root(self) -> Path: - """Walk up from this file to find repo root (contains AIPASS_REGISTRY.json).""" - if self._repo_root is not None: - return self._repo_root - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - self._repo_root = parent - return parent - self._repo_root = Path.cwd() + """Walk up from this file to find the repo root, never reading the cwd. + + Delegates to ``handlers/repo_root.py`` — see that module for why the + fallback must be derived from ``__file__``. The per-instance cache stays: + the shared resolver is cheap but this is called per registry entry. + """ + if self._repo_root is None: + self._repo_root = find_repo_root(Path(__file__)) return self._repo_root def _register_branch(self, branch: dict, base: "Optional[Path]" = None) -> None: @@ -184,7 +205,7 @@ def _load_project_registries(self) -> None: return for project_dir in project_dirs: - for registry_file in sorted(project_dir.glob("*_REGISTRY.json")): + for registry_file in _exact_case_registries(project_dir): try: data = json.loads(registry_file.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: diff --git a/src/aipass/prax/apps/handlers/monitoring/commons_feed.py b/src/aipass/prax/apps/handlers/monitoring/commons_feed.py index 8ec25b3f8..b6de8a9b3 100644 --- a/src/aipass/prax/apps/handlers/monitoring/commons_feed.py +++ b/src/aipass/prax/apps/handlers/monitoring/commons_feed.py @@ -33,6 +33,7 @@ from aipass.cli.apps.modules import console, header, error, warning from aipass.prax.apps.handlers.json import json_handler from aipass.prax.apps.handlers.monitoring.event_queue import MonitoringEvent +from aipass.prax.apps.handlers.repo_root import resolved_file from aipass.prax.apps.handlers.monitoring.telegram_relay import ( init_relay, relay_event, @@ -47,7 +48,7 @@ POLL_INTERVAL = 1.5 BACKFILL_LIMIT = 10 -_PRAX_ROOT = Path(__file__).resolve().parents[3] # monitoring/ -> handlers/ -> apps/ -> prax/ +_PRAX_ROOT = resolved_file(Path(__file__)).parents[3] # monitoring/ -> handlers/ -> apps/ -> prax/ _ECOSYSTEM_ROOT = _PRAX_ROOT.parent CURSOR_TABLES = ("posts", "comments", "votes", "reactions") diff --git a/src/aipass/prax/apps/handlers/repo_root.py b/src/aipass/prax/apps/handlers/repo_root.py new file mode 100644 index 000000000..53e2622a8 --- /dev/null +++ b/src/aipass/prax/apps/handlers/repo_root.py @@ -0,0 +1,208 @@ +# =================== AIPass ==================== +# Name: repo_root.py +# Description: Repo-Root Resolution That Never Reads The Process CWD +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +""" +Repo-Root Resolution Handler + +One answer to "where is the repo root", derived from the FILE and never from the +process working directory. + +WHY THIS EXISTS. Eight copies of the same ``_find_repo_root()`` lived in prax, +each walking up from ``__file__`` for ``AIPASS_REGISTRY.json`` and each ending +``return Path.cwd()``. @memory reported the consequence with a full traceback on +2026-08-31, and it is fleet-wide rather than a prax inconvenience: nearly every +handler in AIPass does ``logger = get_system_logger()`` at MODULE level, so that +walk runs while modules are being IMPORTED. Two failure modes followed: + +1. THE CRASH. ``Path.cwd()`` raises FileNotFoundError when the process working + directory has been deleted — while a module is merely being imported, not + called. Reproduced against a registry-less copy of this tree. + +2. THE QUIET ONE, which outlives the crash. ``AIPASS_REGISTRY.json`` is + gitignored and machine-local, so on EVERY clean CI checkout the walk falls + through and ``system_logs/`` resolves against wherever the caller's shell + happened to stand — silently, and differently per caller. + +THE RULE. Derive the fallback from ``__file__``, never from the process. On a +registry-less checkout the source-derived answer IS the checkout, so it is not a +guess there either. The last resort is the filesystem root: defined, incapable of +raising, and absurd enough to fail loudly downstream instead of quietly resolving +into somebody's home directory. + +@drone and @memory landed this same shape independently in their own trees. + +IMPORT-TIME SAFETY. This module is reached from inside the logger construction +chain, so it imports nothing from prax and uses stdlib ``logging`` only. A +diagnostic must never become the crash it was diagnosing. +""" + +import logging +from pathlib import Path, PurePath +from typing import Optional + +logger = logging.getLogger(__name__) + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +REGISTRY_MARKER = "AIPASS_REGISTRY.json" +SOURCE_DIR_NAME = "src" + + +def resolved_file(path: Path) -> Path: + """``Path.resolve()`` that cannot need a working directory. + + ``__file__`` is already absolute, so on POSIX resolving it only normalises + symlinks and never reads the process state. NOT SO ON WINDOWS: + ``ntpath.realpath`` calls ``os.getcwd()`` unconditionally on its first lines, + before it checks whether the path is even absolute. So every + ``Path(__file__).resolve()`` executed at IMPORT is a working-directory read + on Windows — in a branch whose modules are imported by the whole fleet. + + Measured by @spawn on the Windows CI gate 2026-08-31 inside inspect's stack + walk; prax then found NINE more copies of the bare form at module level, one + of them in this very module. Falling back to the unresolved path is safe + because the input is already absolute; only the symlink normalisation is + lost, and a normalisation is worth less than an import. + + Args: + path: An absolute path, normally ``Path(__file__)``. + + Returns: + The resolved path, or the input unchanged when resolution needs state + the process cannot read. + """ + try: + return path.resolve() + except OSError as exc: + # debug, not warning: on Windows this fires for every module-level + # constant in a process whose cwd is gone, and the unresolved path is a + # correct answer, not a degraded one. A warning per import would be noise + # about a handled condition — but a trail has to exist, because the day + # this fires is the day someone is reading logs asking why. + logger.debug("[repo_root] %s could not be resolved (%s); using it unresolved", path, exc) + return path + + +# Guarded inline rather than through resolved_file(): this constant is built +# before that function is defined, and the module must not reorder around it. +try: + _THIS_FILE = Path(__file__).resolve() +except OSError as _exc: + logger.debug("[repo_root] this module's own path could not be resolved (%s); using it unresolved", _exc) + _THIS_FILE = Path(__file__) + +# The fallback is announced once per process, not once per caller. Every module +# in the branch reaches this during import; a per-call warning would bury CI +# output on exactly the registry-less checkout the warning is about, and a +# fallback nobody can see is how the next one survives. A set rather than a +# rebindable flag, so nothing here needs ``global``. +_ANNOUNCED: set = set() + + +# ============================================================================= +# RESOLUTION +# ============================================================================= + + +def source_root(start: Optional[Path] = None) -> Path: + """Derive the checkout root from a file path, reading no process state. + + Walks up for a directory named ``src`` and returns its parent — the layout + every AIPass branch lives in (``/src/aipass//...``). + + Args: + start: A path inside the checkout. Defaults to this file. A RELATIVE + path is rejected in favour of this file, because resolving one would + read the working directory this function exists to avoid. + + Returns: + The directory containing ``src``, or the filesystem root when no ``src`` + component exists. Never the process working directory. + """ + anchor = start if (start is not None and start.is_absolute()) else _THIS_FILE + return Path(_walk_to_source_root(anchor)) + + +def _walk_to_source_root(anchor: PurePath) -> PurePath: + """The component walk, with no filesystem and no process state in it. + + Split out from :func:`source_root` so it can be exercised on PurePosixPath + AND PureWindowsPath from either platform. @devpulse's Windows CI leg found + prax's first pins asserting on a POSIX literal like ``/x/checkout/src/...`` + — which is NOT absolute as a WindowsPath (rooted, no drive), so the + is_absolute guard in the caller correctly refused it and the test measured + the real checkout instead of the world it meant to build. The logic was + never wrong; the fabricated world was. + """ + for parent in anchor.parents: + if parent.name == SOURCE_DIR_NAME: + return parent.parent + # Deliberately absurd: a defined answer that fails loudly downstream rather + # than a plausible one that quietly writes into somebody's home directory. + return anchor.parents[-1] + + +def find_repo_root(start: Optional[Path] = None) -> Path: + """Find the repo root by marker, falling back to the source root — never cwd. + + Args: + start: A path inside the tree to walk up from; callers pass their own + ``Path(__file__)``. Defaults to this file. A relative path is + replaced by this file rather than resolved, since resolving reads + the working directory. + + Returns: + The directory containing ``AIPASS_REGISTRY.json`` if one is found above + ``start``, else :func:`source_root`. + """ + anchor = start if (start is not None and start.is_absolute()) else _THIS_FILE + for parent in [anchor] + list(anchor.parents): + try: + if (parent / REGISTRY_MARKER).exists(): + return parent + except OSError as exc: + # An unreadable directory is not an answer — keep walking rather + # than let a permissions problem masquerade as "no repo here". + logger.info("[repo_root] %s is unreadable, continuing the walk: %s", parent, exc) + continue + + fallback = source_root(anchor) + _announce_fallback(anchor, fallback) + return fallback + + +def _announce_fallback(anchor: Path, fallback: Path) -> None: + """Say once, out loud, that the marker was never found. + + Uses stdlib logging on purpose — this runs inside the construction of prax's + own logger, and a warning that needs the logger it is being emitted from is + a second crash wearing a diagnostic's clothes. That is @memory's caveat and + it is satisfied by the CHOICE of logger, not by wrapping the call: stdlib + logging already isolates handler failures (``Handler.handleError``), so a + broken handler installed by a host application cannot propagate out of here. + + An outer ``except Exception: pass`` was written here first and then removed. + It only ever caught a condition a test had to invent — replacing this + module's ``logger`` attribute — and guarding what the platform already + guarantees is the same "two guards where one decides" that misled a reader + in introspection.py. The property that matters is pinned structurally + instead: this module imports nothing from prax. + """ + if _ANNOUNCED: + return + _ANNOUNCED.add(REGISTRY_MARKER) + logger.warning( + "[repo_root] %s not found above %s — falling back to the source root %s. " + "Paths derived from the repo root are resolved against the checkout, " + "not the working directory.", + REGISTRY_MARKER, + anchor, + fallback, + ) diff --git a/src/aipass/prax/apps/plugins/devpulse_dashboard/git_section.py b/src/aipass/prax/apps/plugins/devpulse_dashboard/git_section.py index 81949ea2f..ac408be5f 100644 --- a/src/aipass/prax/apps/plugins/devpulse_dashboard/git_section.py +++ b/src/aipass/prax/apps/plugins/devpulse_dashboard/git_section.py @@ -20,9 +20,21 @@ from aipass.prax.apps.modules.logger import system_logger as logger -def _find_repo_root(start: Path) -> Path: - """Walk up from start to find .git directory.""" - current = start.resolve() +def _find_git_root(start: Path) -> Path: + """Walk up from start to find a .git directory. + + RENAMED from _find_repo_root on 2026-08-31. It asks a different question from + the branch's other _find_repo_root functions — .git, not AIPASS_REGISTRY.json + — and sharing their name made it a false positive in the sweep that keeps + those honest. It was never the same defect: it takes an explicit start and + raises rather than falling back to the working directory. + + A relative ``start`` is refused rather than resolved, because resolve() reads + the working directory and a process whose cwd was deleted would die here. + """ + if not start.is_absolute(): + raise ValueError("start must be absolute; resolving a relative path reads the cwd") + current = start while current != current.parent: if (current / ".git").exists(): return current @@ -57,7 +69,7 @@ def build_git_section(branch_path: Path) -> bool: Returns: True if write_section succeeded, False otherwise. """ - repo_root = _find_repo_root(branch_path) + repo_root = _find_git_root(branch_path) # Current branch branch = _run_git(repo_root, "rev-parse", "--abbrev-ref", "HEAD") or "unknown" diff --git a/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py b/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py index dd530891b..25df74dfe 100644 --- a/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py +++ b/src/aipass/prax/apps/plugins/devpulse_dashboard/refresh.py @@ -19,8 +19,17 @@ from aipass.prax.apps.modules.logger import system_logger as logger -# Devpulse branch path — resolved relative to this file's location -_AIPASS_SRC = Path(__file__).resolve().parents[4] # .../src/aipass/ +# Devpulse branch path — derived from this file's location. +# +# No .resolve() here, deliberately. `__file__` is already absolute, so the +# resolve only normalised symlinks — and on Windows `ntpath.realpath` reads the +# process working directory before it checks anything, which made this +# module-level line a cwd read in a plugin that has no business needing one. +# Walking parents does not need resolution: the four directories above this file +# are structural. (prax's handlers route the same problem through +# `handlers/repo_root.resolved_file`, but a plugin may only enter through +# `apps.modules` — so the fix here is to not need it.) +_AIPASS_SRC = Path(__file__).parents[4] # .../src/aipass/ DEVPULSE_PATH = _AIPASS_SRC / "devpulse" diff --git a/src/aipass/prax/tests/test_devpulse_dashboard_plugin.py b/src/aipass/prax/tests/test_devpulse_dashboard_plugin.py index fb6e7007a..64d775582 100644 --- a/src/aipass/prax/tests/test_devpulse_dashboard_plugin.py +++ b/src/aipass/prax/tests/test_devpulse_dashboard_plugin.py @@ -104,7 +104,7 @@ def test_build_git_section_subprocess_failure(self, mock_run, branch_with_git): # Still writes section with empty/default values assert result is True - @patch("aipass.prax.apps.plugins.devpulse_dashboard.git_section._find_repo_root") + @patch("aipass.prax.apps.plugins.devpulse_dashboard.git_section._find_git_root") def test_build_git_section_no_git_dir(self, mock_find_root, branch_path): """Test git section when no .git directory exists.""" from aipass.prax.apps.plugins.devpulse_dashboard.git_section import build_git_section diff --git a/src/aipass/prax/tests/test_json_durability.py b/src/aipass/prax/tests/test_json_durability.py index 157e7c684..45d916d86 100644 --- a/src/aipass/prax/tests/test_json_durability.py +++ b/src/aipass/prax/tests/test_json_durability.py @@ -41,6 +41,8 @@ import errno import json import os +import subprocess +import sys import threading import time from pathlib import Path @@ -347,3 +349,167 @@ def reader(): assert counts["ok"] > 0, "probe never observed a readable document" assert counts["empty"] == 0, f"{counts['empty']} readers saw an empty document" assert counts["unparseable"] == 0, f"{counts['unparseable']} readers saw a partial document" + + +# ============================================================================= +# AIPASS_TEST_LOG_DIR — the fleet seam (2026-08-30, @devpulse dispatch) +# ============================================================================= +# +# prax redirected its log FILES under pytest for a long time +# (config/load.py::get_system_logs_dir) and never gave PRAX_JSON_DIR the same +# branch, so one logger.info() under pytest wrote 4 redirected files and 24 real +# ones into src/aipass/prax/prax_json/. Every branch's suite paid it: @drone +# measured 3449 of their hygiene records as prax's json_handler, @memory 1552, +# @daemon 1096, @backup 778. +# +# The contract is AIPASS_TEST_LOG_DIR in @trigger's form +# (trigger/apps/handlers/json/json_handler.py:35) — deliberately NOT a sixth +# spelling invented here. + + +class TestTestLogDirSeam: + """PRAX_JSON_DIR honours AIPASS_TEST_LOG_DIR, like the log files already do. + + Tested through the pure resolver rather than by reloading the module: this + branch's conftest pulls prax modules out of sys.modules, so importlib.reload + is not available here, and a resolver that can be called with its inputs is + a better seam than one that can only be observed as an import side effect. + """ + + def test_env_var_redirects_out_of_the_real_tree(self, tmp_path): + """The whole point: a suite that sets the var keeps its writes out of prax.""" + resolved = json_handler_mod._resolve_prax_json_dir(str(tmp_path), Path("/real/prax")) + assert resolved == tmp_path / "prax" / "prax_json" + assert Path("/real/prax") not in resolved.parents + + def test_absent_env_var_uses_the_real_tree(self): + """Production must be untouched — absence of the var means the real dir.""" + assert json_handler_mod._resolve_prax_json_dir(None, Path("/real/prax")) == Path("/real/prax/prax_json") + + def test_empty_env_var_is_absence_not_the_filesystem_root(self): + """AIPASS_TEST_LOG_DIR='' must not resolve to /prax/prax_json.""" + assert json_handler_mod._resolve_prax_json_dir("", Path("/real/prax")) == Path("/real/prax/prax_json") + + def test_writes_land_in_the_redirect_not_the_real_tree(self, monkeypatch, tmp_path): + """Call-time resolution: the seam works even though this module was + imported before the conftest set the variable. Import-time resolution + alone left the live constant pointing at the real tree — measured.""" + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + resolved = json_handler_mod._current_json_dir() + assert resolved == tmp_path / "prax" / "prax_json" + assert "Projects" not in resolved.parts, resolved + + def test_an_explicit_override_still_wins_over_the_env(self, monkeypatch, tmp_path): + """~20 tests redirect by patching the attribute; that must keep working.""" + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path / "env")) + monkeypatch.setattr(json_handler_mod, "PRAX_JSON_DIR", tmp_path / "patched") + assert json_handler_mod._current_json_dir() == tmp_path / "patched" + + def test_the_path_builder_resolves_at_call_time(self, monkeypatch, tmp_path): + """The load-bearing pin. A mutation reverting _json_path() to read the + import-time constant survived every other test in this class — the seam + is only real if the USE SITES resolve, not just the resolver.""" + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + built = json_handler_mod.get_json_path("probe", "config") + assert built == tmp_path / "prax" / "prax_json" / "probe_config.json" + assert "Projects" not in built.parts, built + + def test_a_stale_write_back_of_the_real_dir_is_not_an_override(self, monkeypatch, tmp_path): + """@daemon's reload defect, reproduced against prax's own resolver. + + A test that calls importlib.reload while a monkeypatch is live has its + teardown write the PRE-reload Path back onto the POST-reload module. With + the identity check prax originally shipped — and with @daemon's value + comparison too, in this ordering — that stale real-directory value reads + as an explicit override and the redirect silently dies for the rest of the + session. Every one of the 18 branches uses importlib.reload somewhere. + """ + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + real = json_handler_mod._resolve_prax_json_dir(None, json_handler_mod._PRAX_ROOT) + monkeypatch.setattr(json_handler_mod, "PRAX_JSON_DIR", real) + + resolved = json_handler_mod._current_json_dir() + assert resolved == tmp_path / "prax" / "prax_json", ( + "a stale write-back of the real directory was read as a deliberate override" + ) + + def test_a_write_back_equal_to_the_redirect_is_not_an_override(self, monkeypatch, tmp_path): + """@daemon's own ordering: the write-back equals the post-reload default.""" + monkeypatch.setenv("AIPASS_TEST_LOG_DIR", str(tmp_path)) + monkeypatch.setattr(json_handler_mod, "PRAX_JSON_DIR", tmp_path / "prax" / "prax_json") + assert json_handler_mod._current_json_dir() == tmp_path / "prax" / "prax_json" + + def test_no_env_and_no_override_is_the_real_tree(self, monkeypatch): + """Production is untouched when nobody asks for a redirect.""" + monkeypatch.delenv("AIPASS_TEST_LOG_DIR", raising=False) + resolved = json_handler_mod._current_json_dir() + assert resolved.name == "prax_json" and resolved.parent.name == "prax" + + def test_the_import_time_anchor_is_env_independent(self, tmp_path): + """The precondition @drone wrote down when they adopted this contract. + + _current_json_dir() detects an explicit override by comparing against two + fixed points. A reference that is itself derived from the thing being + detected cannot detect it (@daemon's sentence) — so if the anchor is + seeded from AIPASS_TEST_LOG_DIR, then in any run where the variable was + already exported at import time PRAX_JSON_DIR *is* a redirect, and the + moment anything points the variable somewhere else that stale redirect + reads as a deliberate patch and wins forever. + + Measured in a SUBPROCESS on purpose. In-process this is invisible: prax's + own suite is green from the branch directory only because something + imports this module before tests/conftest.py exports the variable — the + anchor lands on the real tree by import-order luck, not by design. From + the repo root another branch's conftest exports it first and the same + assertion goes red. A pin that can only bite in one of the two universes + is not a pin; running the import with the variable set makes the property + observable in both. + """ + code = ( + "from aipass.prax.apps.handlers.json import json_handler as m\n" + "print(m._IMPORT_TIME_JSON_DIR)\n" + "print(m.PRAX_JSON_DIR)\n" + "print(m._resolve_prax_json_dir(None, m._PRAX_ROOT))\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + env={**os.environ, "AIPASS_TEST_LOG_DIR": str(tmp_path)}, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + anchor, live, real = result.stdout.strip().splitlines()[-3:] + + assert anchor == real, f"the anchor is env-derived: {anchor} — it must always be the real tree" + assert live == real, ( + f"PRAX_JSON_DIR was seeded with a redirect: {live} — a later change of " + "AIPASS_TEST_LOG_DIR makes this stale value look like an explicit patch" + ) + + def test_a_redirect_set_before_import_still_follows_a_later_change(self, tmp_path): + """The defect end to end, in the ordering the repo-root suite creates. + + Export the variable, import, then point it somewhere else and ask where a + write would land. It must follow the CURRENT value. Before the anchor was + made env-independent this returned the first redirect for the rest of the + process — the two reds @devpulse reproduced on the CI train. + """ + first, second = tmp_path / "first", tmp_path / "second" + code = ( + "import os\n" + "from aipass.prax.apps.handlers.json import json_handler as m\n" + f"os.environ['AIPASS_TEST_LOG_DIR'] = {str(second)!r}\n" + "print(m.get_json_path('probe', 'config'))\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + env={**os.environ, "AIPASS_TEST_LOG_DIR": str(first)}, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + built = Path(result.stdout.strip().splitlines()[-1]) + + assert built == second / "prax" / "prax_json" / "probe_config.json", ( + f"resolution stuck on the import-time redirect: {built}" + ) diff --git a/src/aipass/prax/tests/test_monitoring_handlers.py b/src/aipass/prax/tests/test_monitoring_handlers.py index 885500a41..cda577db8 100644 --- a/src/aipass/prax/tests/test_monitoring_handlers.py +++ b/src/aipass/prax/tests/test_monitoring_handlers.py @@ -999,6 +999,56 @@ def test_without_filters(self): _FAKE_PROJECTS = _FAKE_HOME / "Projects" +class TestExactCaseRegistries: + """Path.glob is case-INSENSITIVE on Windows, and the registry is a trust anchor. + + @memory found both of their registry walks exposed to this on 2026-08-31 and + closed it with an exact-case post-filter; @devpulse asked whether any prax + walk globbed a cased pattern. One did: the projects/ sweep in branch_detector. + + Pinned by driving the FILTER with a stand-in glob, so the assertion runs on + Linux — where the real glob is case-sensitive and could never produce the + input that breaks it. A live-directory test here would be green on the only + platform I can run and silent on the one that has the defect. + """ + + def test_a_lowercase_registry_is_rejected_even_when_the_glob_returns_it(self, tmp_path, monkeypatch): + mod = _import_branch_detector() + + real = tmp_path / "PROJECT_REGISTRY.json" + windows_only = tmp_path / "project_registry.json" + for path in (real, windows_only): + path.write_text("{}") + + # Stand in for the Windows matcher: return BOTH, the way it would there. + monkeypatch.setattr(type(tmp_path), "glob", lambda self, _pattern: iter([real, windows_only])) + + assert mod._exact_case_registries(tmp_path) == [real] + + def test_the_exact_case_name_still_passes(self, tmp_path): + mod = _import_branch_detector() + real = tmp_path / "AIPASS_REGISTRY.json" + real.write_text("{}") + (tmp_path / "notes.json").write_text("{}") + + assert mod._exact_case_registries(tmp_path) == [real] + + def test_the_stand_in_really_would_have_passed_the_bad_name_through(self, tmp_path, monkeypatch): + """Positive control: without the filter the lowercase file gets in. + + Otherwise a stand-in that quietly returned one path would make the pin + vacuous and it would read as proof. + """ + real = tmp_path / "PROJECT_REGISTRY.json" + windows_only = tmp_path / "project_registry.json" + for path in (real, windows_only): + path.write_text("{}") + monkeypatch.setattr(type(tmp_path), "glob", lambda self, _pattern: iter([real, windows_only])) + + unfiltered = sorted(tmp_path.glob("*_REGISTRY.json")) + assert windows_only in unfiltered + + class TestFindRepoRoot: """Tests for BranchDetector._find_repo_root().""" @@ -1011,51 +1061,75 @@ def test_cached_repo_root_returned(self): result = detector._find_repo_root() assert result is sentinel - def test_walks_parents_to_find_registry(self): - """Should walk up from __file__ to find AIPASS_REGISTRY.json.""" + def test_walks_parents_to_find_registry(self, tmp_path, monkeypatch): + """Should find AIPASS_REGISTRY.json above the module file. + + Rewritten 2026-08-31. The previous version mocked the Path class inside + branch_detector to fake a walk that no longer lives there — it asserted + the shape of an implementation, so it went red the moment the walk moved + into handlers/repo_root.py while the ANSWER stayed identical. This drives + the real resolver over a real directory instead. + """ + from aipass.prax.apps.handlers import repo_root as repo_root_mod + mod = _import_branch_detector() detector = _make_detector_with_branches(mod) detector._repo_root = None - mock_parent = MagicMock(spec=Path) - mock_registry = MagicMock(spec=Path) - mock_registry.exists.return_value = True - mock_parent.__truediv__ = MagicMock(return_value=mock_registry) + root = tmp_path / "checkout" + start = root / "src" / "aipass" / "prax" / "a.py" + start.parent.mkdir(parents=True) + (root / "AIPASS_REGISTRY.json").write_text("{}") + monkeypatch.setattr(mod, "find_repo_root", lambda _start=None: repo_root_mod.find_repo_root(start)) - mock_resolved = MagicMock() - mock_resolved.parent = mock_parent - mock_resolved.parents = [] + assert detector._find_repo_root() == root - with patch(f"{mod.__name__}.Path") as mock_path_cls: - mock_path_cls.return_value.resolve.return_value = mock_resolved - result = detector._find_repo_root() + def test_a_registry_less_tree_never_falls_back_to_the_cwd(self, tmp_path, monkeypatch): + """REPLACES test_fallback_to_cwd_when_registry_missing. - assert result is mock_parent + That test asserted the defect: @memory reported on 2026-08-31 that the + Path.cwd() fallback crashes any import in a process whose working + directory was deleted, and silently resolves against the caller's shell + on every registry-less checkout — which is every clean CI clone, since + the marker is gitignored. The old pin is not weakened here, it is + INVERTED: the cwd answer is now the wrong one, so the assertion says so. + """ + from aipass.prax.apps.handlers import repo_root as repo_root_mod - def test_fallback_to_cwd_when_registry_missing(self): - """Should fall back to Path.cwd() when no AIPASS_REGISTRY.json found.""" mod = _import_branch_detector() detector = _make_detector_with_branches(mod) detector._repo_root = None - mock_parent = MagicMock(spec=Path) - mock_no_registry = MagicMock(spec=Path) - mock_no_registry.exists.return_value = False - mock_parent.__truediv__ = MagicMock(return_value=mock_no_registry) - mock_parent.parents = [] + start = tmp_path / "checkout" / "src" / "aipass" / "prax" / "a.py" + start.parent.mkdir(parents=True) + monkeypatch.setattr(mod, "find_repo_root", lambda _start=None: repo_root_mod.find_repo_root(start)) - mock_resolved = MagicMock() - mock_resolved.parent = mock_parent - mock_resolved.parents = [] + resolved = detector._find_repo_root() + assert resolved == tmp_path / "checkout" + assert resolved != Path.cwd() + + def test_the_answer_is_cached_per_instance(self): + """The cache survived the delegation — it is called per registry entry.""" + mod = _import_branch_detector() + detector = _make_detector_with_branches(mod) + detector._repo_root = None - fake_cwd = Path("/fake/cwd") + calls = [] - with patch(f"{mod.__name__}.Path") as mock_path_cls: - mock_path_cls.return_value.resolve.return_value = mock_resolved - mock_path_cls.cwd.return_value = fake_cwd - result = detector._find_repo_root() + def counting(start=None): + calls.append(start) + return Path("/counted/root") + + original = mod.find_repo_root + mod.find_repo_root = counting + try: + first = detector._find_repo_root() + second = detector._find_repo_root() + finally: + mod.find_repo_root = original - assert result is fake_cwd + assert first == second == Path("/counted/root") + assert len(calls) == 1 class TestRegisterBranch: diff --git a/src/aipass/prax/tests/test_repo_root.py b/src/aipass/prax/tests/test_repo_root.py new file mode 100644 index 000000000..8631949bf --- /dev/null +++ b/src/aipass/prax/tests/test_repo_root.py @@ -0,0 +1,1280 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_repo_root.py - Repo-Root Resolution Pins +# Date: 2026-08-31 +# Version: 1.0.0 +# Category: prax/tests +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-08-31): Initial creation — @memory's dead-cwd report, plus the +# second crash site the sweep found in introspection +# +# CODE STANDARDS: +# - Pytest function style (no unittest classes) +# - Dead-cwd behaviour is proved in a SUBPROCESS — a test that deletes its own +# working directory in-process cannot get back +# ============================================= + +"""Pins for repo-root resolution that must never read the process CWD. + +@memory reported on 2026-08-31, with a full traceback: config/load.py's +_find_repo_root ended `return Path.cwd()`, and because nearly every handler in +AIPass does `logger = get_system_logger()` at module level, that walk runs during +IMPORT. A process whose working directory has been deleted crashed importing +almost anything; a registry-less checkout (every clean CI clone — the registry is +gitignored) silently resolved system_logs/ against wherever the shell stood. + +Prax carried EIGHT copies of that function. The cure is one shared module, and +the guard against the ninth copy is structural: an AST sweep, with a positive +control so it cannot be quietly silenced. + +The sweep found a second crash site @memory's traceback could not reach, because +their caller was a real absolute file: introspection.detect_branch_from_path +resolves the CALLER's path, and a pseudo-filename like is relative, so +resolve() reads the cwd there too. +""" + +import ast +import os +import subprocess +import sys +import textwrap +from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath + +import pytest + +from aipass.prax.apps.handlers import repo_root as repo_root_mod + + +def _answer(result) -> str: + """The last line a probe printed — its answer, not its noise.""" + return result.stdout.strip().splitlines()[-1] if result.stdout.strip() else "" + + +APPS_DIR = Path(repo_root_mod.__file__).resolve().parents[1] +REPO_SRC = str(Path(repo_root_mod.__file__).resolve().parents[5]) + + +# The condition these tests pin is "os.getcwd() raises" — NOT "a directory was +# deleted". Deleting the working directory is one CAUSE of that condition, and it +# is the one cause Windows makes structurally impossible: the OS locks a process's +# current directory, so rmtree fails with WinError 32 and the world dies at SETUP, +# never reaching the claim. @memory owns the shared recipe and ruled on +# 2026-08-31: inject the condition instead. Same call site, same exception, same +# import-time crash — and it runs on every platform. +# +# A skipif(win32) would have been an honest test stating its world, and it is +# still refused: it would retire the pin on the exact platform whose CI leg found +# the defect. TestBothConstructionsAgree below licenses the substitution — the +# injection stands in for the deletion only for as long as that test says the two +# worlds give the identical answer from the identical call site. +_INJECT_DEAD_CWD = """ +import os as _os + + +def _no_working_directory(*_args, **_kwargs): + raise FileNotFoundError(2, "No such file or directory") + + +_os.getcwd = _no_working_directory +try: + _os.getcwd() + print("INVALID-PROBE: the working directory still answers") + raise SystemExit(3) +except FileNotFoundError: + pass +""" + +# CI's world, built on Linux. @spawn measured the mechanism on the Windows gate +# 2026-08-31: ntpath.realpath calls os.getcwd() unconditionally on its first +# lines — before it checks whether the path is even absolute — and inspect's +# getmodule() calls os.path.realpath OUTSIDE a try. posixpath.realpath does not +# take that path, and the equivalent POSIX raise happens inside getabsfile() +# where inspect swallows it. That is the whole reason a denied working directory +# was survivable on Linux and fatal on Windows. +# +# Denying os.path.realpath is therefore not a second world — it is the SAME +# denial reaching the call Windows actually makes. Against the pre-cure guard +# these pins go red on Linux; the getcwd-only world stays green, which is the +# demonstration of why Linux missed it. +# +# CORRECTION 2026-08-31, and it matters for anyone reading these pins as +# evidence: this world convicts an unguarded MODULE-LEVEL Path(__file__). +# resolve(). It does NOT convict inspect.stack(), and I reported to @devpulse +# that it did. What actually happens, measured three ways: getcwd-denied -> +# stack SURVIVES; getcwd AND realpath denied -> stack SURVIVES; realpath denied +# ALONE -> stack DIES. The reason is _NTPATH_SHAPED below. @trigger and @seedgo +# both flagged it before I measured it; they were right. +_DENY_REALPATH = ( + _INJECT_DEAD_CWD + + """ +import os.path as _ospath + + +def _no_realpath(*_args, **_kwargs): + raise FileNotFoundError(2, "No such file or directory") + + +_ospath.realpath = _no_realpath +""" +) + +# THE WORLD THAT CONVICTS A STACK WALK. realpath raises, getcwd still answers. +# +# That combination looks artificial until you look at what Windows does. +# `ntpath.abspath` does not call `os.getcwd()` at all — it calls the Win32 +# `_getfullpathname`, which reads the process directory from the OS and sails +# straight past a patched (or broken) getcwd. `ntpath.realpath` DOES call +# os.getcwd(), in Python, on its first lines. So on a Windows box with no usable +# working directory, abspath works and realpath raises — which is exactly this +# world, and exactly why inspect dies there. +# +# Follow it through inspect: getsourcefile() only calls getmodule() for a +# filename that does not exist on disk (`` qualifies, which is why these +# probes are fed on stdin). getmodule() opens with getabsfile(), wrapped in +# `except (TypeError, FileNotFoundError)` — so denying getcwd makes abspath +# raise there and inspect SWALLOWS it, returning None before the dangerous line. +# Let abspath succeed and the walk reaches `modulesbyfile[os.path.realpath(f)]`, +# which is not in any try. That is the unguarded call, and reaching it requires +# a working getcwd. +# +# So the getcwd denial is not merely insufficient here — it is what HIDES the +# defect. A world can be too hostile to convict. +_NTPATH_SHAPED = """ +import os.path as _ospath + + +def _no_realpath(*_args, **_kwargs): + raise FileNotFoundError(2, "No such file or directory") + + +_ospath.realpath = _no_realpath +""" + +# WINDOWS, EMULATED ON LINUX. Three facts about ntpath vs posixpath in 3.12: +# 1. ntpath.realpath calls os.getcwd() unconditionally on its first lines. +# 2. ntpath.abspath does NOT — it calls the Win32 _getfullpathname, which reads +# the process directory from the OS and never touches os.getcwd. +# 3. posixpath routes BOTH through os.getcwd. +# +# That third fact is the whole POSIX/Windows asymmetry, and it is why the same +# injected getcwd denial produces opposite verdicts on the two platforms. On +# Windows abspath survives the denial and realpath does not, so inspect's +# getabsfile succeeds and the walk reaches the unguarded realpath: ALL THREE +# worlds below convict there. On Linux the denial kills abspath first, inside +# getabsfile's own except, and inspect returns None before the dangerous line. +# +# Measured on the real Windows runner 2026-08-31 (run 33431848734, reported by +# @devpulse): getcwd-denied DIED, realpath-denied DIED, ntpath-shaped DIED. +# This world reproduces that reading on Linux, which is the only way prax can +# check it — there is no Windows box here. +_WINDOWS_EMULATED = """ +import os as _os +import os.path as _ospath + +_OS_LEVEL_CWD = _os.getcwd() +_real_realpath = _ospath.realpath +_real_join = _ospath.join +_real_normpath = _ospath.normpath + + +def _no_working_directory(*_args, **_kwargs): + raise FileNotFoundError(2, "No such file or directory") + + +def _win_abspath(path): + path = _os.fspath(path) + if not _ospath.isabs(path): + path = _real_join(_OS_LEVEL_CWD, path) + return _real_normpath(path) + + +def _win_realpath(path, *_args, **_kwargs): + _os.getcwd() + return _real_realpath(path, *_args, **_kwargs) + + +_ospath.abspath = _win_abspath +_ospath.realpath = _win_realpath +_os.getcwd = _no_working_directory +""" + +# The measured answer to "which worlds kill inspect.stack()", PER PLATFORM. +# +# This table used to be one universal shape asserting SURVIVED / SURVIVED / +# DIED, and it went red on the real Windows runner — correctly. The control was +# built to refuse a POSIX expectation passing silently where it is false, and +# the first platform it met was the one that proved it necessary. Encoding a +# measurement from one OS as a property of the interpreter is the same mistake +# as the one the pins in this file exist to catch, one level up. +EXPECTED_WORLD_VERDICTS = ( + {"getcwd-denied": "DIED", "realpath-denied": "DIED", "ntpath-shaped": "DIED"} + if os.name == "nt" + else {"getcwd-denied": "SURVIVED", "realpath-denied": "SURVIVED", "ntpath-shaped": "DIED"} +) + +_DELETE_CWD = """ +import os as _os, shutil as _shutil, tempfile as _tempfile +_d = _tempfile.mkdtemp() +_os.chdir(_d) +_shutil.rmtree(_d) +try: + _os.getcwd() + print("INVALID-PROBE: the working directory still answers") + raise SystemExit(3) +except OSError: + pass +""" + + +# Without this the dead-cwd probes are weaker than they look on a developer +# machine: the marker walk SUCCEEDS here, so the fallback that reads the working +# directory is never reached and the probe proves only that the happy path does +# not need a cwd. CI has no registry (it is gitignored), which is why the defect +# showed there and not here. Making the marker unfindable puts every machine in +# CI's world for the length of the probe. +_MARKER_ABSENT = """ +from aipass.prax.apps.handlers import repo_root as _rr +_rr.REGISTRY_MARKER = "AIPASS_REGISTRY_THAT_CANNOT_EXIST.json" +""" + + +def _run_without_a_working_directory(body: str, world: str = _INJECT_DEAD_CWD, marker_absent: bool = True): + """Run a snippet in a process whose working directory cannot be read.""" + script = ( + world + + textwrap.dedent( + f""" + import sys + sys.path.insert(0, {REPO_SRC!r}) + """ + ) + + (_MARKER_ABSENT if marker_absent else "") + + textwrap.dedent(body) + ) + # Fed on STDIN, not with -c, and that detail is the test. A `-c` caller + # produces no usable stack filename so introspection bails early; a `` + # caller produces a RELATIVE pseudo-filename, which is exactly what + # detect_branch_from_path then hands to resolve(). + return subprocess.run([sys.executable, "-"], input=script, capture_output=True, text=True, timeout=120) + + +# ============================================================================= +# THE SHARED RESOLVER +# ============================================================================= + + +class TestSourceRoot: + """The fallback is derived from the FILE, and nothing else. + + The component walk is pinned through _walk_to_source_root on BOTH path + dialects, from either platform. @devpulse's Windows CI leg found the first + version of these tests asserting on ``Path("/x/checkout/src/...")``, which is + rooted-but-driveless and therefore NOT absolute as a WindowsPath — so + source_root's guard refused the fabricated start and answered with the real + checkout. The verdict was TEST-WORLD, not production: source_root calls no + resolve() at all, and the module's only resolve() is on __file__, which is + absolute and needs no working directory. A fabricated POSIX literal simply + stops being the world you meant on the other platform. + """ + + @pytest.mark.parametrize("flavour", [PurePosixPath, PureWindowsPath]) + def test_the_directory_containing_src_is_the_checkout(self, flavour): + start = flavour("/x/checkout/src/aipass/prax/a.py") + assert repo_root_mod._walk_to_source_root(start) == flavour("/x/checkout") + + @pytest.mark.parametrize("flavour", [PurePosixPath, PureWindowsPath]) + def test_no_src_component_falls_to_the_root(self, flavour): + """Defined, incapable of raising, and absurd enough to fail loudly. + + A plausible-looking answer here is worse than an absurd one: it would + resolve quietly into somebody's home directory. + """ + walked = repo_root_mod._walk_to_source_root(flavour("/nowhere/at/all/a.py")) + assert walked == flavour("/") + + def test_a_drive_qualified_windows_path_walks_to_its_own_checkout(self): + """The spelling a real Windows caller actually produces.""" + start = PureWindowsPath(r"D:\a\AIPass\AIPass\src\aipass\prax\a.py") + assert repo_root_mod._walk_to_source_root(start) == PureWindowsPath(r"D:\a\AIPass\AIPass") + + @pytest.mark.parametrize("flavour", [PurePosixPath, PureWindowsPath]) + def test_the_src_match_is_exact_case_not_folded(self, flavour): + """A deliberate semantic, pinned because a mutation folding it survived. + + Case-folding the component match would "work" on Windows and GUESS on + Linux, where SRC/ and src/ are genuinely different directories. Guessing + across case is the same move that made Path.glob read a lowercase file as + the registry trust anchor. An unrecognised layout takes the loud last + resort instead — which is what the last resort is for. + """ + assert repo_root_mod._walk_to_source_root(flavour("/x/SRC/aipass/prax/a.py")) == flavour("/") + + def test_a_relative_start_is_refused_not_resolved(self): + """Resolving it would read the working directory this module exists to avoid.""" + assert repo_root_mod.source_root(Path("relative/a.py")) == repo_root_mod.source_root() + + def test_a_rooted_driveless_start_is_refused_on_windows_spelling(self): + """The exact shape that made the CI leg red, now stated as the rule. + + source_root only trusts an ABSOLUTE start. On Windows a rooted path with + no drive is not absolute, so it is refused — correctly, because resolving + it would read the working directory. Pinned so nobody 'fixes' the guard + to accept it. + """ + assert not PureWindowsPath("/x/checkout/src/aipass/prax/a.py").is_absolute() + assert PurePosixPath("/x/checkout/src/aipass/prax/a.py").is_absolute() + + +class TestFindRepoRoot: + """Marker first, source root second, working directory never.""" + + def test_the_marker_wins_when_present(self, tmp_path): + root = tmp_path / "checkout" + (root / "src" / "aipass" / "prax").mkdir(parents=True) + (root / repo_root_mod.REGISTRY_MARKER).write_text("{}") + assert repo_root_mod.find_repo_root(root / "src" / "aipass" / "prax" / "a.py") == root + + def test_a_registry_less_checkout_resolves_to_the_checkout(self, tmp_path): + """Every clean CI clone: the marker is gitignored, so it is never there.""" + start = tmp_path / "checkout" / "src" / "aipass" / "prax" / "a.py" + start.parent.mkdir(parents=True) + assert repo_root_mod.find_repo_root(start) == tmp_path / "checkout" + + def test_a_relative_start_is_refused_not_walked_from_the_cwd(self, tmp_path, monkeypatch): + """The mutation this kills: accepting a relative start. + + It survives every dead-cwd pin, because with a relative path the walk + simply finds nothing and falls through. The damage shows with a LIVE cwd: + a caller standing inside some other checkout would have the marker found + THERE, and this function would confidently return somebody else's repo. + """ + foreign = tmp_path / "someone_elses_checkout" + (foreign / "src" / "aipass" / "prax").mkdir(parents=True) + (foreign / repo_root_mod.REGISTRY_MARKER).write_text("{}") + monkeypatch.chdir(foreign) + + # Walking this relative start reaches Path(".") — which IS the foreign + # checkout — and finds the marker there. + resolved = repo_root_mod.find_repo_root(Path("src/aipass/prax/a.py")) + assert resolved not in (Path("."), foreign), ( + f"a relative start was walked from the working directory: {resolved}" + ) + assert resolved == repo_root_mod.find_repo_root() + + def test_the_fallback_is_announced(self, tmp_path, caplog, monkeypatch): + """A fallback nobody can see is how the next one survives (@memory).""" + monkeypatch.setattr(repo_root_mod, "_ANNOUNCED", set()) + start = tmp_path / "checkout" / "src" / "aipass" / "prax" / "a.py" + start.parent.mkdir(parents=True) + with caplog.at_level("WARNING", logger=repo_root_mod.__name__): + repo_root_mod.find_repo_root(start) + assert repo_root_mod.REGISTRY_MARKER in caplog.text + + def test_the_announcement_is_once_per_process_not_once_per_call(self, tmp_path, caplog, monkeypatch): + """Every module in the branch reaches this during import — a per-call + warning would bury CI output on exactly the checkout it is about.""" + monkeypatch.setattr(repo_root_mod, "_ANNOUNCED", set()) + start = tmp_path / "checkout" / "src" / "aipass" / "prax" / "a.py" + start.parent.mkdir(parents=True) + with caplog.at_level("WARNING", logger=repo_root_mod.__name__): + for _ in range(5): + repo_root_mod.find_repo_root(start) + assert caplog.text.count("[repo_root]") == 1 + + def test_this_module_imports_nothing_from_prax(self): + """@memory's caveat, pinned structurally instead of with a try/except. + + This runs inside the construction of prax's own logger. A diagnostic that + needs the logger it is being emitted from is a second crash wearing a + diagnostic's clothes — so the guarantee is that there is nothing here to + recurse INTO, not that a recursion would be caught. + """ + tree = ast.parse(Path(repo_root_mod.__file__).read_text(encoding="utf-8")) + imported = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported += [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + imported.append(node.module) + assert not [name for name in imported if name.startswith("aipass")], imported + + +# ============================================================================= +# THE DEAD CWD — proved, not reasoned +# ============================================================================= + + +WORLDS = [ + pytest.param(_INJECT_DEAD_CWD, id="getcwd-denied"), + pytest.param(_DENY_REALPATH, id="realpath-denied"), +] + +# The import-crash pins above use WORLDS. The caller-attribution pins below need +# a third, because the two above are both too hostile to reach the call that +# breaks — see _NTPATH_SHAPED. Kept as a separate list rather than appended, so +# that nobody widens the import pins onto a world where getcwd works and quietly +# stops testing the thing those pins exist for. +ATTRIBUTION_WORLDS = WORLDS + [pytest.param(_NTPATH_SHAPED, id="ntpath-shaped")] + + +@pytest.mark.parametrize("world", WORLDS) +class TestDeadCwd: + """@memory's condition: the working directory cannot be read and something imports. + + Run in BOTH worlds, because one of them was invisible here. getcwd-denied is + the condition as stated. realpath-denied is the same denial reaching the call + Windows actually makes — ntpath.realpath asks for the working directory + unconditionally, and inspect's getmodule calls it outside a try. Against the + pre-cure guard the realpath world is red on Linux and the getcwd world is + green; that difference IS the reason the Windows gate found this and eleven + Linux runs did not. + """ + + def test_the_resolver_answers_without_a_working_directory(self, world): + result = _run_without_a_working_directory( + world=world, + body=""" + from aipass.prax.apps.handlers import repo_root + from pathlib import Path + print("ANSWER", repo_root.find_repo_root(Path("/no/such/src/aipass/x/a.py"))) + """, + ) + assert result.returncode == 0, result.stderr + assert "ANSWER" in result.stdout, result.stderr + + def test_the_logger_can_be_built_without_a_working_directory(self, world): + """The end-to-end case, and the one @memory's traceback could not reach. + + Their caller was a real absolute file, so resolution of the CALLER path + succeeded and the walk got as far as load.py. Run from a pseudo-filename + — a heredoc, an embedded interpreter, any `` caller — and + introspection.detect_branch_from_path resolves a RELATIVE path first and + dies one frame earlier. + """ + result = _run_without_a_working_directory( + world=world, + body=""" + from aipass.prax.apps.modules.logger import get_system_logger + print("LOGGER", get_system_logger().name) + """, + ) + assert result.returncode == 0, result.stderr + assert "LOGGER" in result.stdout, result.stderr + + def test_the_fallback_path_is_the_one_being_exercised(self, world): + """Positive control on the WORLD, not the claim — and the honesty note. + + On this machine the marker walk succeeds, so without _MARKER_ABSENT these + probes would only prove the happy path needs no working directory. That + is exactly why the defect showed on CI (no registry — it is gitignored) + and not here. This asserts the answer really is the source-root fallback, + so the branch under test is the one that used to read Path.cwd(). + + What this pin CANNOT do is fail against the pre-fix code on a machine that + has a registry: the old walk hardcoded its own marker, so nothing in this + process could make it miss. That negative control needs a registry-less + checkout, and it was run as one — @memory's traceback, reproduced and then + cured, 2026-08-31. + """ + result = _run_without_a_working_directory( + world=world, + body=""" + from aipass.prax.apps.handlers import repo_root + from pathlib import Path + print("ANSWER", repo_root.find_repo_root(Path(repo_root.__file__))) + """, + ) + assert result.returncode == 0, result.stderr + assert _answer(result).split(" ", 1)[1] == str(repo_root_mod.source_root()) + + def test_the_system_logs_dir_resolves_without_a_working_directory(self, world): + result = _run_without_a_working_directory( + world=world, + body=""" + from aipass.prax.apps.handlers.config.load import get_system_logs_dir + print("LOGS", get_system_logs_dir()) + """, + ) + assert result.returncode == 0, result.stderr + assert "LOGS" in result.stdout, result.stderr + + +class TestCallerPathResolution: + """introspection._resolve_caller_path — the second crash site. + + @memory's traceback stopped at config/load.py because their caller was a real + absolute file. Run the same probe from a heredoc and detect_branch_from_path + dies ONE FRAME EARLIER, resolving a relative pseudo-filename. + """ + + def test_a_pseudo_filename_is_not_a_path(self): + from aipass.prax.apps.handlers.logging import introspection + + assert introspection._resolve_caller_path("") is None + assert introspection._resolve_caller_path("") is None + + def test_a_relative_caller_is_refused_even_when_the_cwd_is_alive(self, tmp_path, monkeypatch): + """The half the dead-cwd pin cannot see, and the reason this guard exists + for correctness and not only for crashes. + + With a LIVE working directory a relative caller path resolves fine — to + the wrong place. It would be attributed to whatever branch the caller's + shell happened to be standing in, silently. Dropping the is_absolute + guard survives the dead-cwd pin (the try/except catches that) and is + killed only here. + """ + from aipass.prax.apps.handlers.logging import introspection + + branch_dir = tmp_path / "src" / "aipass" / "someoneelse" / "apps" + branch_dir.mkdir(parents=True) + (branch_dir / "mod.py").write_text("") + monkeypatch.chdir(branch_dir) + + assert introspection._resolve_caller_path("mod.py") is None + + def test_an_absolute_caller_still_resolves(self, tmp_path): + from aipass.prax.apps.handlers.logging import introspection + + real = tmp_path / "mod.py" + real.write_text("") + assert introspection._resolve_caller_path(str(real)) == real.resolve() + + def test_an_unresolvable_absolute_path_is_an_answer_not_a_crash(self, monkeypatch): + """Branch detection is a routing hint. Not knowing where the caller lives + must never take down the caller's import.""" + from aipass.prax.apps.handlers.logging import introspection + + def boom(self, *_args, **_kwargs): + raise OSError("no cwd") + + monkeypatch.setattr(Path, "resolve", boom) + assert introspection._resolve_caller_path("/absolute/but/unresolvable.py") is None + + +class TestBothConstructionsAgree: + """The licence for substituting the injected world for the deleted one. + + @memory's ruling allows injecting a raising os.getcwd in place of deleting + the working directory, because the CONDITION is what the pins are about. This + class is the condition on that licence: on POSIX both worlds are buildable, + so both must produce the IDENTICAL answer from the IDENTICAL call site. The + day they diverge, the stand-in stops being a stand-in and these go red rather + than the substitution quietly drifting. + + Skipped on Windows for the reason the substitution exists at all — the OS + locks a process's current directory, so the deletion world cannot be built + there to compare against. + """ + + PROBE = """ + from aipass.prax.apps.handlers import repo_root + from pathlib import Path + print("ANSWER", repo_root.find_repo_root(Path("/no/such/src/aipass/x/a.py"))) + """ + + @pytest.mark.skipif( + sys.platform == "win32", + reason="the deleted-cwd world is unbuildable on Windows — WinError 32, the OS locks it", + ) + def test_the_injected_world_and_the_deleted_world_give_the_same_answer(self): + injected = _run_without_a_working_directory(self.PROBE, world=_INJECT_DEAD_CWD) + deleted = _run_without_a_working_directory(self.PROBE, world=_DELETE_CWD) + + assert injected.returncode == 0, injected.stderr + assert deleted.returncode == 0, deleted.stderr + assert _answer(injected) == _answer(deleted), ( + f"injected={_answer(injected)!r} deleted={_answer(deleted)!r} — the stand-in no longer stands in" + ) + + @pytest.mark.skipif( + sys.platform == "win32", + reason="the deleted-cwd world is unbuildable on Windows — WinError 32, the OS locks it", + ) + def test_both_worlds_really_do_break_the_working_directory(self): + """Positive control on the WORLDS, not on the claim. + + Each recipe's own INVALID-PROBE guard fires if the working directory + still answers; this proves the guard is reachable, so a world that + silently failed to break anything could not pass as a dead-cwd test. + """ + probe = """ + import os + try: + os.getcwd() + print("ALIVE") + except OSError: + print("BROKEN") + """ + for name, world in (("injected", _INJECT_DEAD_CWD), ("deleted", _DELETE_CWD)): + result = _run_without_a_working_directory(probe, world=world) + assert "BROKEN" in result.stdout, f"{name}: {result.stdout!r} {result.stderr!r}" + + +# ============================================================================= +# THE SWEEP — no ninth copy +# ============================================================================= + +# The single sanctioned working-directory read in prax: `drone @prax dashboard +# refresh` with no argument means "the branch I am standing in", and the cwd IS +# the question there rather than a fallback for a question it could not answer. +CWD_ALLOWLIST = {"apps/modules/dashboard.py"} + + +def _module_key(relative: PurePath) -> str: + """The one spelling a module is named by, on every platform. + + Normalised at the sweep BOUNDARY rather than at each comparison, because a + separator-sensitive key is the kind of thing that gets fixed in one place and + stays broken in the next. Path.relative_to returns the OS's own separators, + so on Windows the raw str() disagreed with the forward-slash allowlist and + the sweep convicted a line it had been told to ignore (@devpulse, Windows CI, + 2026-08-31). + """ + return relative.as_posix() + + +def _modules_reading_the_cwd(root: Path) -> dict: + """AST scan: which files call Path.cwd() or os.getcwd()?""" + found = {} + for path in sorted(root.rglob("*.py")): + if ".archive" in path.parts or "__pycache__" in path.parts: + continue + # Deliberately NOT skipped. A module in prax's own tree that will not + # parse is a hole in this sweep, and a sweep that skips its holes reports + # the same green as a sweep that found nothing wrong. @canary made the + # same call this morning after a SyntaxError mutant scored as a SURVIVOR: + # an instrument that produced no reading must never be graded as a pass. + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (SyntaxError, UnicodeDecodeError) as exc: + raise AssertionError(f"{path} could not be parsed, so this sweep cannot see it: {exc}") from exc + lines = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + owner = node.func.value + name = getattr(owner, "id", None) + if (name == "Path" and node.func.attr == "cwd") or (name == "os" and node.func.attr == "getcwd"): + lines.append(node.lineno) + if lines: + found[_module_key(path.relative_to(root.parent))] = lines + return found + + +class TestNoPrivateCwdFallback: + """Eight identical copies is how this survived. The ninth is a red test.""" + + def test_no_module_outside_the_allowlist_reads_the_working_directory(self): + offenders = { + module: lines for module, lines in _modules_reading_the_cwd(APPS_DIR).items() if module not in CWD_ALLOWLIST + } + assert not offenders, ( + "these modules resolve against the process working directory — derive " + f"from __file__ via handlers/repo_root.py instead: {offenders}" + ) + + def test_the_sweep_can_actually_see_a_violation(self, tmp_path): + """Positive control. A sweep that finds nothing because it looks nowhere + reports the same green as a clean tree.""" + planted = tmp_path / "apps" / "planted.py" + planted.parent.mkdir(parents=True) + planted.write_text("from pathlib import Path\n\n\ndef f():\n return Path.cwd()\n") + assert _modules_reading_the_cwd(tmp_path / "apps") + + def test_the_allowlist_matches_a_windows_spelled_key(self): + """@devpulse's Windows CI leg: the sweep convicted an EXEMPTED line. + + Path.relative_to gives back the OS's own separators, so on Windows the + key was ``apps\\modules\\dashboard.py`` while the allowlist holds a + forward-slash literal. No match, so an allowlisted file was reported as + an offender — the sweep accused the one line it had been told to ignore. + + Pinned with a Windows-spelled relative path so it runs red-first on + Linux; a live-path assertion could never have caught this from here. + """ + assert _module_key(PureWindowsPath(r"apps\modules\dashboard.py")) in CWD_ALLOWLIST + assert _module_key(PurePosixPath("apps/modules/dashboard.py")) in CWD_ALLOWLIST + + def test_the_allowlist_names_only_files_that_exist(self): + """An allowlist entry for a deleted file is a hole nobody can see.""" + for entry in CWD_ALLOWLIST: + assert (APPS_DIR.parent / entry).exists(), entry + + +class TestEveryLaneUsesTheSharedResolver: + """Structural: no lane keeps a private walk (@memory's sweep shape). + + Paired deliberately with the cwd sweep above, because each covers the other's + blind spot. This one is NAME-based and a rename defeats it — but a renamed + private walk still has to end somewhere, and if it ends at the working + directory the cwd sweep names it. Matching the marker string instead was + tried and rejected: a dozen modules legitimately READ AIPASS_REGISTRY.json, + and a sweep that cannot tell reading the file from walking for the root gets + muted for crying wolf. + """ + + def test_every_find_repo_root_delegates_to_the_shared_resolver(self): + private = {} + for path in sorted(APPS_DIR.rglob("*.py")): + if ".archive" in path.parts or "__pycache__" in path.parts: + continue + if path == Path(repo_root_mod.__file__).resolve(): + continue + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if node.name != "_find_repo_root": + continue + delegates = any( + isinstance(inner, ast.Call) + and getattr(inner.func, "id", getattr(inner.func, "attr", None)) == "find_repo_root" + for inner in ast.walk(node) + ) + if not delegates: + private.setdefault(_module_key(path.relative_to(APPS_DIR.parent)), []).append(node.lineno) + assert not private, ( + f"these modules walk for the repo root themselves instead of calling handlers/repo_root.py: {private}" + ) + + def test_the_delegation_sweep_can_actually_see_a_violation(self, tmp_path): + """Positive control: a sweep that looks nowhere reports the same green.""" + planted = tmp_path / "planted.py" + planted.write_text("from pathlib import Path\n\n\ndef _find_repo_root():\n return Path.cwd()\n") + tree = ast.parse(planted.read_text()) + offenders = [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) + and node.name == "_find_repo_root" + and not any( + isinstance(inner, ast.Call) + and getattr(inner.func, "id", getattr(inner.func, "attr", None)) == "find_repo_root" + for inner in ast.walk(node) + ) + ] + assert offenders, "the sweep would not have seen a planted private walk" + + def test_the_lanes_agree_with_the_shared_answer(self): + """Behavioural, not just structural: same question, one answer.""" + from aipass.prax.apps.handlers.config import load as load_mod + from aipass.prax.apps.handlers.dashboard import refresh as refresh_mod + from aipass.prax.apps.handlers.dashboard import status as status_mod + + expected = repo_root_mod.find_repo_root() + assert load_mod._find_repo_root() == expected + assert refresh_mod._find_repo_root() == expected + assert status_mod._find_repo_root() == expected + + +# ============================================================================= +# THE STACK WALK ITSELF — a structural instrument, because no import-shaped +# pin can reach the branch the deleted walk lived in +# ============================================================================= + + +def _inspect_stack_calls(tree: ast.AST) -> list: + """Every ``inspect.stack()`` CALL in a parsed module, by line number. + + A call, never a spelling. The guard's own docstring names ``inspect.stack()`` + three times while explaining why it must not be used, so a string ban would + convict the explanation and force the cure to be undocumented — the + instrument would be arguing against the record it depends on. + """ + return sorted( + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "stack" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "inspect" + ) + + +def _docstrings(tree: ast.AST) -> list: + """Every docstring in a parsed module — module, class and function.""" + return [ + ast.get_docstring(node) + for node in ast.walk(tree) + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) + and ast.get_docstring(node) + ] + + +def _tree_modules() -> list: + """Every live module under apps/, excluding archives and bytecode.""" + return [ + path + for path in sorted(APPS_DIR.rglob("*.py")) + if ".archive" not in path.parts and "__pycache__" not in path.parts + ] + + +class TestNothingCallsInspectStack: + """`inspect.stack()` is banned across apps/, and the ban is a CALL match. + + WHY A STRUCTURAL PIN AND NOT A BEHAVIOURAL ONE. @devpulse measured this + across six branches: the guard's `caller_file is None` branch — where the + deleted second walk lived — is UNREACHABLE from any import-shaped pin, + because apps/__init__ always supplies a real-file frame. @aipass ran the + experiment: restoring the walk left 1118 of 1120 tests green, and only the + AST assertions died. So every dead-cwd pin in this file could stay green + while somebody regrows the exact call that took Windows CI down. + + WHY THE BAN IS TREE-WIDE rather than scoped to the guard. After this round's + two cures (config/load.py's log-dir resolver and json_handler's caller + attribution) prax has no legitimate caller left, so the wider ban costs + nothing and catches the next one wherever it lands. `inspect.currentframe()` + is deliberately NOT banned: it is `sys._getframe` under another name and + touches no filesystem. + + THE MECHANISM, for whoever reads this after the next regression. + `inspect.stack()` builds a FrameInfo per frame -> getsourcefile() -> + getmodule() -> `modulesbyfile[os.path.realpath(f)]`, and that realpath is + not inside a try. On Windows `ntpath.realpath` calls `os.getcwd()` + unconditionally on its first lines, before it checks whether the path is + even absolute. So the whole call needs a readable working directory, on + Windows, before any of the caller's own code runs. + """ + + def test_no_module_under_apps_calls_inspect_stack(self): + offenders = {} + for path in _tree_modules(): + tree = ast.parse(path.read_text(encoding="utf-8")) + lines = _inspect_stack_calls(tree) + if lines: + offenders[_module_key(path.relative_to(APPS_DIR.parent))] = lines + assert not offenders, ( + "inspect.stack() needs a readable working directory on Windows and dies at import " + f"in a process that has none. Use sys._getframe(n).f_code.co_filename: {offenders}" + ) + + def test_the_walk_actually_parsed_something(self): + """Negative control for the sweep: a blinded walk reads clean too. + + The pin above is a proof of absence, and a proof of absence from an + instrument that looked nowhere is worth nothing. This asserts the walk + found a real tree before its silence is allowed to mean anything. + """ + modules = _tree_modules() + assert len(modules) > 50, ( + f"the apps/ walk found only {len(modules)} modules — the sweep above is reading a " + "tree that is not there, so its green is vacuous" + ) + + def test_the_matcher_convicts_a_planted_call_at_the_right_line(self, tmp_path): + """Positive control, through the REAL matcher rather than a copy of it.""" + planted = tmp_path / "planted.py" + planted.write_text("import inspect\n\n\ndef f():\n return inspect.stack()[1]\n") + assert _inspect_stack_calls(ast.parse(planted.read_text())) == [5] + + def test_the_matcher_does_not_convict_the_docstring_that_explains_the_ban(self): + """The guard's docstring says `inspect.stack()` and must stay legal. + + This is the pin that makes the ban survivable. A spelling ban would make + the cure's own explanation illegal, and the next person would delete the + explanation rather than the defect. + + REBUILT 2026-08-31 after @spawn caught the same shape in their tree and + @devpulse relayed it. The first version asserted the whole live guard + file was clean — which is a SECOND COPY OF THE BAN wearing a control's + name. It proved it in my own mutant run: regrowing the walk redded this + test too, and I reported that as a bonus kill when it was the control + failing for the ban's reason. A control that can fail for the reason it + is controlling for is not a control. + + So the fixture is the guard's REAL docstrings and nothing else, lifted + by ast and re-emitted as string literals. That module contains no calls + by construction, so regrowing the walk cannot touch this test — and it + still uses live prose, so it expires the day the explanation is deleted. + """ + guard = APPS_DIR / "handlers" / "__init__.py" + tree = ast.parse(guard.read_text(encoding="utf-8")) + mentions = [text for text in _docstrings(tree) if "inspect.stack()" in text] + assert mentions, ( + "the guard no longer explains why it does not use inspect.stack() — if the " + "explanation was removed to satisfy a checker, the checker is the defect" + ) + prose_only = "\n".join(repr(text) for text in mentions) + assert _inspect_stack_calls(ast.parse(prose_only)) == [] + + @pytest.mark.parametrize( + "source", + [ + pytest.param("import numpy\n\nx = numpy.stack([1, 2])\n", id="numpy.stack"), + pytest.param("import traceback\n\nx = traceback.stack()\n", id="traceback.stack"), + pytest.param("x = self.stack()\n", id="self.stack"), + pytest.param("import inspect\n\nx = inspect.currentframe()\n", id="inspect.currentframe"), + ], + ) + def test_the_matcher_clears_things_that_only_look_like_it(self, source): + """Somebody else's `.stack` is not this defect. Convicting it teaches + branches to route around the instrument, which is how a checker dies.""" + assert _inspect_stack_calls(ast.parse(source)) == [] + + +class TestCallerAttributionWithoutAWorkingDirectory: + """The two `inspect.stack()` sites this round cured, pinned by BEHAVIOUR. + + Both were found by peers running prax in their own denied worlds — @memory + and @trigger independently, on the same morning. Neither was fatal on Linux + and one was not fatal anywhere, which is exactly why they needed pins that + do not depend on a crash. + """ + + def test_the_ntpath_shaped_world_actually_kills_a_stack_walk(self): + """Negative control FOR the positive controls: is each world hostile? + + Written after measuring, not before, and the measurement corrected me. + A bare inspect.stack() in each world: + + getcwd-denied -> SURVIVES + getcwd + realpath denied -> SURVIVES + realpath denied alone -> DIES + + So the two worlds this file already had could never have convicted a + stack walk, and the pins that went red against the pre-cure guard were + reading the module-level Path(__file__).resolve() next door. Without + this control the two pins below would pass in two of three worlds for a + reason unrelated to what they claim to measure. + """ + verdicts = {} + for world, name in ( + (_INJECT_DEAD_CWD, "getcwd-denied"), + (_DENY_REALPATH, "realpath-denied"), + (_NTPATH_SHAPED, "ntpath-shaped"), + ): + probe = subprocess.run( + [sys.executable, "-"], + input=world + + textwrap.dedent( + """ + import inspect + + try: + inspect.stack() + print("SURVIVED") + except BaseException: + print("DIED") + """ + ), + capture_output=True, + text=True, + timeout=120, + ) + verdicts[name] = probe.stdout.strip() + + assert verdicts == EXPECTED_WORLD_VERDICTS, ( + f"the worlds no longer bite the way {os.name} was measured to bite. Expected " + f"{EXPECTED_WORLD_VERDICTS}, got {verdicts}. If the new reading is real, re-derive the " + "table from measurement rather than relaxing the assertion — this control exists to " + "refuse a platform's expectation being carried onto a platform where it is false." + ) + + @pytest.mark.parametrize("world", ATTRIBUTION_WORLDS) + def test_the_log_dir_resolver_names_its_caller_without_a_cwd(self, world): + """config/load.py's `get_module_logs_dir()` with no module_name. + + This one was NOT caught and NOT logged — a bare `inspect.stack()[1]` in + the primary local-log-directory resolver. On a Windows box whose cwd is + gone it raises, from the logging path, at the moment logging is the only + thing that could tell anyone what went wrong. + """ + result = _run_without_a_working_directory( + """ + import os + import tempfile + + os.environ.setdefault("AIPASS_TEST_LOG_DIR", tempfile.mkdtemp(prefix="prax_probe_logs_")) + from aipass.prax.apps.handlers.config.load import get_module_logs_dir + + print("LOGDIR", get_module_logs_dir().name) + """, + world=world, + ) + assert result.returncode == 0, ( + "get_module_logs_dir() could not name its caller without a working directory.\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + # is the caller here, so the stem is 'stdin' — the point is that a + # name was derived at all, from a frame rather than from the filesystem. + assert "LOGDIR " in result.stdout, f"no directory came back: {result.stdout}" + + @pytest.mark.parametrize("world", ATTRIBUTION_WORLDS) + def test_the_operations_log_still_attributes_its_caller(self, world): + """json_handler's `_get_caller_module_name`. + + Guarded and logged, so it survives — and records every operation as + "unknown". @trigger saw it twice in a single import chain. Degraded is + not cured: the audit log stops naming anyone exactly when a machine is + in the state that makes the log worth reading. + + THE CALLER IS A REAL FILE ON DISK, written for this probe, and that is + load-bearing in two directions. It has to be real, because after the + pseudo-frame cure "unknown" is the CORRECT answer for a `` + caller — so a stdin caller leaves this pin unable to tell a frame with + no module from a frame read that failed, which is the whole claim. And + it must not be the ONLY frame: @commons measured that a stack of purely + on-disk frames makes getsourcefile early-return before getmodule, so the + realpath denial goes inert. The probe itself still runs from stdin, so + that frame stays on the stack and inspect still reaches the unguarded + call. Real name available, world still hostile. + """ + result = _run_without_a_working_directory( + """ + import sys + import tempfile + from pathlib import Path + + CALLER_SOURCE = [ + "from aipass.prax.apps.handlers.json import json_handler", + "", + "", + "def stands_in_for_log_operation():", + " return json_handler._get_caller_module_name()", + "", + "", + "def ask():", + " # Two levels: _get_caller_module_name skips [0]=itself and", + " # [1]=log_operation to reach [2], the real caller.", + " return stands_in_for_log_operation()", + ] + + caller_dir = Path(tempfile.mkdtemp(prefix="prax_caller_")) + (caller_dir / "a_real_caller.py").write_text(chr(10).join(CALLER_SOURCE), encoding="utf-8") + sys.path.insert(0, str(caller_dir)) + + import a_real_caller + + print("CALLER", a_real_caller.ask()) + """, + world=world, + ) + assert result.returncode == 0, ( + f"importing json_handler died without a working directory.\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert "CALLER a_real_caller" in result.stdout, ( + "the caller was not attributed to the real file that asked — either the frame read " + f"needed a working directory and threw the answer away, or it named the wrong " + f"frame.\nstdout: {result.stdout}" + ) + + +class TestTheGuardsUndeterminableCallerBranch: + """The `caller_file is None` branch, pinned by BEHAVIOUR as well as by AST. + + @devpulse's round-4 guidance said this branch is unreachable and only a + structural ban can watch it. @spawn measured the correction and it is worth + stating precisely, because I repeated the too-strong version: the branch is + unreachable from IMPORT-shaped pins — apps/__init__ always supplies a + real-file frame — but it IS reachable by calling `_guard_branch_access()` + from a child whose every frame is a string pseudo-file or importlib. All of + those are skipped, `_find_real_caller` returns None, and the branch runs. + + So the true sentence is "import-shaped pins cannot reach it", not "nothing + can". The AST ban stays: it needs no subprocess and names the defect at its + line. This is its sibling, and the two die together. + """ + + def _child(self, body: str): + """Run `body` with realpath denied, in a `-c` child. + + `-c`, not a script file, and that is @commons' lesson rather than a + style choice: run the probe as a real file on disk and EVERY frame + getsourcefile sees exists, so it early-returns before getmodule and the + denial is silently inert. The world would be spelled too realistically + to bite. + """ + script = ( + _NTPATH_SHAPED + + textwrap.dedent( + f""" + import sys + sys.path.insert(0, {REPO_SRC!r}) + """ + ) + + textwrap.dedent(body) + ) + return subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=120) + + def test_the_branch_is_reachable_and_the_guard_returns_from_it(self): + """Both arming probes, then the claim — in that order, in one child. + + Probe 1 proves the denial bites in THIS process shape (a `-c` child, not + the stdin shape the rest of this file uses). Probe 2 proves + `_find_real_caller` actually returned None, so a future change cannot + quietly route the test down the ordinary caller path and still pass. + Only then is the guard called. + """ + result = self._child( + """ + import inspect + + from aipass.prax.apps import handlers + + # ARMING PROBE 1 — is the world hostile at all? + try: + inspect.stack() + print("ARM1 INERT") + except BaseException: + print("ARM1 BITES") + + # ARMING PROBE 2 — does the branch under test actually run? + print("ARM2", handlers._find_real_caller()) + + # THE CLAIM. + handlers._guard_branch_access() + print("GUARD RETURNED") + """ + ) + assert "ARM1 BITES" in result.stdout, ( + "the realpath denial is inert in a -c child, so this test proves nothing about a " + f"stack walk.\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert "ARM2 (None, None)" in result.stdout, ( + "_find_real_caller found a caller, so the undeterminable-caller branch never ran and " + f"the claim below is about a different code path.\nstdout: {result.stdout}" + ) + assert "GUARD RETURNED" in result.stdout, ( + "the guard did not return from its undeterminable-caller branch in a process with no " + f"usable realpath.\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert result.returncode == 0, f"child exited {result.returncode}: {result.stderr}" + + +# Characters Windows refuses in a path component. Checked on every platform, +# because the point is that a name derived here must be creatable EVERYWHERE — +# Linux will happily make a directory called `` and did, dozens of times, +# before anyone noticed. +_WINDOWS_RESERVED_CHARS = set('<>:"|?*') + + +class TestACallerNameIsNotAlwaysAModuleName: + """A frame's co_filename is not always a file, and never automatically a name. + + FOUND BY THE WINDOWS RUNNER, 2026-08-31 (run 33431848734, @devpulse). Three + parametrised worlds of test_the_log_dir_resolver_names_its_caller_without_a_cwd + went red there and none of them went red here, and the working directory + turned out to have nothing to do with it. + + `get_module_logs_dir()` derives its caller's module name from a frame and + then MKDIRS it. From `python -c`, from stdin, from an eval or a frozen + loader, that frame's co_filename is a pseudo-filename — ``, + ``, `` — and `Path("").stem` is + ``. On Linux that silently creates a directory called ``; I + found ten of them on this machine, made by these very pins. On Windows `<` + and `>` are reserved and mkdir raises, from the logging path, which is + exactly where an unhandled raise is worst. + + So the defect is not the working directory and it is not new — the old + `inspect.stack()[1]` read the same co_filename and had the same hole. The + round-4 pin is simply the first thing that ever called this from a + pseudo-frame on a platform that objects. + + THE RULE: a pseudo-frame has no module behind it, so it gets the same answer + as no caller at all. Guessing a name from `<...>` is worse than admitting + there is none — it invents an attribution AND builds a directory for it. + """ + + def test_the_log_dir_from_a_pseudo_frame_is_creatable_on_every_platform(self): + """The claim the Windows runner actually made, checkable here.""" + result = _run_without_a_working_directory( + """ + import os + import tempfile + + os.environ["AIPASS_TEST_LOG_DIR"] = tempfile.mkdtemp(prefix="prax_probe_logs_") + from aipass.prax.apps.handlers.config.load import get_module_logs_dir + + print("LOGDIR", get_module_logs_dir().name) + """, + world=_NTPATH_SHAPED, + ) + assert result.returncode == 0, f"probe died: {result.stdout}\n{result.stderr}" + name = _answer(result).split("LOGDIR ", 1)[1] + offending = sorted(_WINDOWS_RESERVED_CHARS & set(name)) + assert not offending, ( + f"get_module_logs_dir() derived the directory name {name!r} from a pseudo-frame and " + f"created it. Windows refuses {offending} in a path component, so this raises there — " + "from the logging path. A frame with no file behind it has no module name to give." + ) + + def test_the_operations_log_does_not_attribute_to_a_pseudo_frame(self): + """json_handler reads the same co_filename and had the same hole. + + Less damaging — it names a record rather than creating a directory — but + an operations log that attributes work to `` is asserting + something false about who did it. + """ + result = _run_without_a_working_directory( + """ + from aipass.prax.apps.handlers.json import json_handler + + + def stands_in_for_log_operation(): + return json_handler._get_caller_module_name() + + + def the_actual_caller(): + return stands_in_for_log_operation() + + + print("CALLER", the_actual_caller()) + """, + world=_NTPATH_SHAPED, + ) + assert result.returncode == 0, f"probe died: {result.stdout}\n{result.stderr}" + name = _answer(result).split("CALLER ", 1)[1] + assert not (_WINDOWS_RESERVED_CHARS & set(name)), ( + f"the operations log attributed an entry to {name!r} — a pseudo-frame is not a module, " + "and 'unknown' is the honest answer where there is nobody to name" + ) + + @pytest.mark.skipif(os.name == "nt", reason="on Windows this world IS the platform, not an emulation") + def test_the_windows_emulation_reproduces_the_measured_windows_table(self): + """The emulation is only worth having if it reads like the real runner. + + prax has no Windows box, so every Windows claim here is made through + _WINDOWS_EMULATED. This asserts that world produces the table @devpulse + measured on run 33431848734 — all three worlds convicting — rather than + the POSIX table. Without it the emulation is an assertion about ntpath + wearing the clothes of a measurement. + """ + verdicts = {} + for world, name in ( + (_INJECT_DEAD_CWD, "getcwd-denied"), + (_DENY_REALPATH, "realpath-denied"), + (_NTPATH_SHAPED, "ntpath-shaped"), + ): + # The emulation goes FIRST: it captures the real cwd before any + # world takes getcwd away, which is what Win32 _getfullpathname has + # and a patched os.getcwd does not. + probe = subprocess.run( + [sys.executable, "-"], + input=_WINDOWS_EMULATED + + world + + textwrap.dedent( + """ + import inspect + + try: + inspect.stack() + print("SURVIVED") + except BaseException: + print("DIED") + """ + ), + capture_output=True, + text=True, + timeout=120, + ) + verdicts[name] = _answer(probe) + + assert verdicts == { + "getcwd-denied": "DIED", + "realpath-denied": "DIED", + "ntpath-shaped": "DIED", + }, f"the Windows emulation does not read like the Windows runner did: {verdicts}" diff --git a/src/aipass/prax/tests/test_watcher.py b/src/aipass/prax/tests/test_watcher.py index 2721f862c..3f78ac874 100644 --- a/src/aipass/prax/tests/test_watcher.py +++ b/src/aipass/prax/tests/test_watcher.py @@ -11,6 +11,7 @@ - apps/handlers/discovery/watcher.py (PythonFileWatcher, start/stop_file_watcher) """ +import subprocess import sys from pathlib import Path from unittest.mock import MagicMock, patch @@ -790,3 +791,116 @@ def __exit__(self, *exc): f"the lock was taken {acquisitions['n']} times for 200 log calls — " "the throttle check has moved inside the lock and every log call now contends" ) + + +# ============================================================================ +# THE OPTIONAL TRIGGER IMPORT - it must never take prax's import down +# ============================================================================ + +_DENY_TRIGGER = ''' +import importlib.abc +import sys + +TARGET = "aipass.trigger.apps.modules.core" + + +class DenyTrigger(importlib.abc.MetaPathFinder): + """Make the optional cross-branch import fail the way it failed for real.""" + + def find_spec(self, fullname, path=None, target=None): + if fullname == TARGET: + raise OSError(2, "No such file or directory") + return None + + +sys.meta_path.insert(0, DenyTrigger()) +''' + + +def _run_with_trigger_denied(body: str) -> subprocess.CompletedProcess: + """Run ``body`` in a fresh interpreter where importing trigger raises OSError. + + A subprocess because the claim is about IMPORT TIME: watcher.py's fallback runs + once, at module level, and this process already imported it successfully. Fed on + STDIN rather than -c so a traceback names real line numbers. + """ + script = _DENY_TRIGGER + body + return subprocess.run( + [sys.executable, "-"], + input=script, + text=True, + capture_output=True, + cwd=str(Path(__file__).resolve().parents[3]), + ) + + +class TestOptionalTriggerIntegration: + """`trigger` is declared optional. The fallback must be as wide as the failures. + + MEASURED 2026-08-31: it was not. The except clause caught only ImportError, and + @trigger's own handlers/__init__.py guard raised FileNotFoundError — an OSError, + not an ImportError — while resolving a frame filename without a working + directory. So prax's whole logger import chain died on a failure in a dependency + prax had already declared it could live without. The bug is not that trigger + raised; a peer branch is allowed to be broken. The bug is that "graceful + fallback if trigger not available" was written to cover one spelling of + unavailable. + """ + + def test_the_denial_is_live(self): + """Negative control FOR the positive control below. + + If the injected finder never fires, the next test passes for the boring + reason that trigger imports fine on this machine — a green that proves + nothing. This asserts the hostile world is actually hostile before any + test relies on it. + """ + result = _run_with_trigger_denied( + "\n" + "try:\n" + " import aipass.trigger.apps.modules.core # noqa: F401\n" + "except BaseException as exc:\n" + " print('DENIED', type(exc).__name__, isinstance(exc, OSError))\n" + "else:\n" + " print('NOT DENIED')\n" + ) + # FileNotFoundError, not the bare OSError raised: Python's errno mapping + # picks the subclass for errno 2. That is exactly the exception @trigger's + # guard produced on the real machine, so the world is faithful, not merely + # similar. The pin asserts the CATEGORY the fallback has to cover. + assert "DENIED FileNotFoundError True" in result.stdout, ( + "the meta_path finder did not deny the trigger import, so every pin built " + f"on this world is vacuous.\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + def test_an_oserror_from_the_optional_import_falls_back(self): + """Prax imports, and knows trigger is absent, when trigger raises OSError.""" + result = _run_with_trigger_denied( + "\nfrom aipass.prax.apps.handlers.discovery import watcher\nprint('HAS_TRIGGER', watcher._HAS_TRIGGER)\n" + ) + assert result.returncode == 0, ( + "an OSError from the OPTIONAL trigger import killed prax's watcher import.\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert "HAS_TRIGGER False" in result.stdout, ( + "the import survived but still believes trigger is available — the fallback " + f"did not run.\nstdout: {result.stdout}" + ) + + def test_the_public_logger_survives_it_too(self): + """The chain that actually broke: modules/logger.py -> watcher -> trigger. + + Pinned at the public entry point rather than only at the handler, because + the handler is an implementation detail and every other branch in the fleet + reaches this through `get_system_logger`. + """ + result = _run_with_trigger_denied( + "\n" + "from aipass.prax.apps.modules.logger import get_system_logger\n" + "get_system_logger().info('the optional dependency is absent and that is fine')\n" + "print('LOGGER OK')\n" + ) + assert "LOGGER OK" in result.stdout, ( + "prax's public logger could not be built while trigger was unavailable.\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) diff --git a/src/aipass/seedgo/README.md b/src/aipass/seedgo/README.md index 1487006fa..113aec8cc 100644 --- a/src/aipass/seedgo/README.md +++ b/src/aipass/seedgo/README.md @@ -123,7 +123,9 @@ seedgo/ │ │ ├── permissions.py # TRUSTED_CROSS_WRITERS list for hook + drone auth │ │ ├── readme_update.py # README generation module │ │ └── test_map.py # Custom function test coverage mapping -│ └── handlers/ # 10 handler directories +│ └── handlers/ # 12 handler directories + 2 shared modules +│ ├── module_root.py # Guarded module_file() — the one import-time __file__ resolve +│ ├── registry_scan.py # Case-EXACT registry discovery — the one reader every lane uses │ ├── aipass_standards/ # 45 checker standards (132 files: 45 check + 45 content │ │ │ # + 38 md + applicability.py, skip_dirs.py, │ │ │ # diagnostics.json, __init__.py) @@ -153,8 +155,10 @@ seedgo/ │ ├── diagnostics/ # Pyright integration + branch discovery │ ├── json/ # JSON tracking (json_handler) │ ├── readme/ # README generator + branch resolution +│ ├── audit_tests/ # audit-tests execution lane (write-gated suite run) +│ ├── tests_pytest_standards/ # pytest-standards adapter pack for the audit-tests lane │ └── test_map/ # Function test coverage scanner -├── tests/ # 57 test files, 2362 tests +├── tests/ # 59 test files, 2691 tests ├── .trinity/ # Identity + memory ├── .aipass/ # Branch prompt (aipass_local_prompt.md) ├── .seedgo/ # Self-bypass rules + audit artifacts diff --git a/src/aipass/seedgo/apps/handlers/__init__.py b/src/aipass/seedgo/apps/handlers/__init__.py index 13442aeae..9cbb26106 100644 --- a/src/aipass/seedgo/apps/handlers/__init__.py +++ b/src/aipass/seedgo/apps/handlers/__init__.py @@ -1,6 +1,7 @@ """Seedgo handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "aipass.seedgo" @@ -10,33 +11,61 @@ def _find_real_caller(): """ Walk the stack to find the actual file that triggered this import. - Skips: - - This file (handlers/__init__.py) - - Python's importlib internals - - Frozen modules - - Returns tuple: (file_path, import_line) or (None, None) + Skips this file, importlib internals, and frozen modules. + Returns tuple: (file_path, import_line) or (None, None). + + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on the + Windows CI gate 2026-08-31 (@spawn's finding, relayed by @devpulse): + inspect.stack() needs a READABLE CWD, and it needs one before any of this + function's own code runs. It builds a FrameInfo per frame, which calls + getsourcefile() -> getmodule(); getsourcefile returns EARLY for any file + that exists on disk, so getmodule is only reached for the frozen importlib + frames an import puts on the stack — and there, getmodule's module-scan loop + calls os.path.realpath OUTSIDE any try. On POSIX the walk raises earlier, in + getabsfile, where inspect catches FileNotFoundError and returns None; on + Windows ntpath.abspath SUCCEEDS, so control reaches the unprotected + realpath. That asymmetry is why this was invisible on Linux for as long as + it existed. A frame's co_filename is already a string in memory; reading it + touches nothing. """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) + # Path.resolve() reaches the same realpath, so this is guarded too — __file__ + # is already absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back + continue - for frame_info in stack: - filename = frame_info.filename + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename # Skip this file - if this_file in str(Path(filename).resolve()): + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - # Skip Python internals - if filename.startswith("<") or "importlib" in filename: - continue - - # Found a real file - try to get the import line - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + # linecache is what inspect used for code_context; called directly it + # reads one named file and returns "" rather than raising when it cannot. + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -71,14 +100,12 @@ def _guard_branch_access(): _guard_logger.info("import_line = %s", import_line) if caller_file is None: - # Can't determine caller from real files - # Check if we're being run from command line (external) - # by looking at the raw stack for or - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return # Allow command-line Python through - return # Allow if truly can't determine + # No caller outside this file: an interactive session, a -c script, or an + # importlib-only stack. All three are allowed. This used to walk + # inspect.stack() a SECOND time looking for / and then + # return either way — a second copy of the cwd dependency above, in + # service of a branch that could not change the answer. + return # Allow pytest — test files need to import handlers for testing if "pytest" in caller_file or "/_pytest/" in caller_file: diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_check.py index 355f8a757..ade8ab381 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_check.py @@ -29,6 +29,7 @@ # with spawn's name in the traceback, not quietly score the fleet against a # guess. The mirror this replaces is retired (DPLAN-0319 wave 3, FPLAN-0454). from aipass.spawn.apps.modules import get_template_dir, refuse_legacy_class +from aipass.seedgo.apps.handlers.module_root import module_file # Audit scope: all Python files AUDIT_SCOPE = "all_files" @@ -37,7 +38,7 @@ # failed 439 of the fleet's 456 test files (96%, every branch) -- noise, not findings. APPLIES_TO = "production" -PACK_ROOT = Path(__file__).resolve().parent.parent.parent # aipass_standards/ -> handlers/ -> apps/ -> seedgo/ +PACK_ROOT = module_file(__file__).parent.parent.parent # aipass_standards/ -> handlers/ -> apps/ -> seedgo/ # Spawn templates root — live-scanned. The SUBDIRECTORY under it is never # guessed from citizen_class: passport 2.0 (DPLAN-0319) made the class a diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/cli.md b/src/aipass/seedgo/apps/handlers/aipass_standards/cli.md index b2c1b07d1..d7836cbc0 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/cli.md +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/cli.md @@ -1028,3 +1028,27 @@ This isn't about making things harder by building two interfaces. It's about mak **Service Provider Pattern:** CLI transforms all CLI activities through single import. Import console, use it throughout. Consistency across the system, maintained in one place. Build both. Serve both audiences. + +## Exemption — relaying a captured subprocess stream + +`sys.stdout.write(...)` / `sys.stderr.write(...)` is **allowed** when the argument is a +completed subprocess's captured stream and the streams **correspond**: + +```python +sys.stdout.write(result.stdout) # ✓ relay +sys.stderr.write(result["stderr"]) # ✓ relay +sys.stdout.write("hello") # ✗ your own output — use Rich +sys.stdout.write(f"{n} done") # ✗ +sys.stdout.write(rendered_table) # ✗ +sys.stdout.write(result.stderr) # ✗ crossed streams — a routing bug +``` + +**Why.** This rule means *route your own output through Rich*. `@drone` is a router: it +relays a child process's bytes verbatim, and `console.print` would interpret markup in, +wrap, and re-style output drone never authored — precisely what a router must not do. +The checker was grepping a **verb** where the rule asks an **authorship** question +(raised via `@devpulse`, 2026-08-31). + +The exemption keys on the **argument**, so it cannot be borrowed by writing your own +string; and it requires the stream names to match, so a captured `stderr` written to +`stdout` stays a violation — that is a real bug this check should keep catching. diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_check.py index 83c3d9544..1cb065313 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_check.py @@ -30,6 +30,108 @@ APPLIES_TO = "production" +#: `sys.stdout.write(x.stdout)`, `sys.stderr.write(x["stderr"])` and friends — +#: a router handing a completed subprocess's captured output straight through. +#: The stream names must CORRESPOND: writing a captured stderr to stdout is a +#: routing bug this check should keep catching, so it is not matched here. +_RELAYED_STREAM = re.compile( + r"sys\.(?Pstdout|stderr)\.write\(\s*[A-Za-z_][\w.]*" + r"(?:\.(?P=sink)\b|\[\s*[\"'](?P=sink)[\"']\s*\]|\.get\(\s*[\"'](?P=sink)[\"'])" +) + + +def _is_raw_write_violation(line: str) -> bool: + """Is this line a raw stream write the CLI standard should object to? + + The rule means "route YOUR OWN output through Rich". @drone's router relays + a child process's captured bytes verbatim — `sys.stdout.write(result.stdout)` + — and Rich would interpret markup in, wrap and re-style output drone never + authored, which is exactly what a router must not do (@devpulse, 2026-08-31). + The checker was grepping a VERB where the rule asks an AUTHORSHIP question. + + The exemption is keyed on the ARGUMENT so it cannot be borrowed: a literal, + an f-string or a value you built is still a violation. It also requires the + streams to correspond, so a captured stderr written to stdout stays red. + """ + code_part = line.split("#")[0] if "#" in line else line + if "sys.stdout.write(" not in code_part and "sys.stderr.write(" not in code_part: + return False + return not _RELAYED_STREAM.search(code_part) + + +def _console_import_line(content: str) -> Optional[int]: + """Line number of the module's OWN module-level import of the cli console. + + Returns None when the module never imports it — which is deliberately NOT + an exemption on its own. A module that has no console has nothing this + standard would recognise as the right instrument, and exempting all of them + would hand every future modules/ file a blanket pass for raw writes. In the + checker's current scope (modules/ and apps/ entry points) that clause alone + exempts 0 sites today and an unbounded number tomorrow, which is exactly the + kind of hole a single clause hides. Measured, then rejected. + + Args: + content: Full file source. + + Returns: + The 1-based line of the earliest module-level ``aipass.cli`` import, or + None if the module never imports one. + """ + try: + tree = ast.parse(content) + except SyntaxError as exc: + # A file this checker cannot parse gets no window and keeps every + # finding. Ignorance is not evidence of a bootstrap. + logger.info("cli: no console-import window, unparseable: %s", exc) + return None + best = None + for node in tree.body: + if not isinstance(node, (ast.Import, ast.ImportFrom)): + continue + module = getattr(node, "module", None) or (node.names[0].name if node.names else "") + if module and module.startswith("aipass.cli"): + best = node.lineno if best is None else min(best, node.lineno) + return best + + +def _is_pre_console_bootstrap(line_number: int, console_line: Optional[int]) -> bool: + """True for a raw write in the window before the console exists. + + THE WINDOW IS REAL, not a preference. @commons' entry point removes apps/ + from sys.path[0] so commons.py cannot shadow the commons package, and that + repair must run BEFORE any cross-branch import — importing @cli up there is + the very thing the repair exists to make safe. So in those few lines the + console this standard demands does not exist yet, and the rule is not + "wrong", it is UNSATISFIABLE. A checker that convicts on an unsatisfiable + clause teaches branches to take waivers. + + TWO CLAUSES, and the pair is what makes it narrow (measured 2026-08-31): + 1. the module imports the cli console AT ALL — so the exemption is only + ever available to code that does use Rich, twenty lines further down; + 2. the write LEXICALLY PRECEDES that import — the window, and nothing + after it. + Clause 2 alone is meaningless without an import to precede. Clause 1 alone + exempts every console-less module. Together they clear exactly ONE site in + the fleet, the one reported. + + Lexical order is the right test rather than a stricter "before any + cross-branch import", because the write's only alternative instrument is + the console, and the console's availability is decided by that one import + line. @commons proved the point by MEASURING the alternative — moving the + prax import above their repair works today — and reverting it anyway, + because "no leak" is a fact about another branch's import hygiene that + their entry point would then silently depend on forever. + + Args: + line_number: 1-based line of the raw write. + console_line: Result of :func:`_console_import_line`. + + Returns: + True if the write sits in the pre-console window. + """ + return console_line is not None and line_number < console_line + + def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: """ Check if module follows CLI standards @@ -170,6 +272,62 @@ def _console_print_in_string(line: str, stripped: str) -> bool: return False +def _stderr_directed_print_lines(content: str) -> set: + r"""Lines holding a ``print(..., file=sys.stderr)`` call. + + THE RULE ALREADY AGREED, on one of its two spellings. ``check_module`` + scores a handler 100 for ``sys.stderr.write(msg)`` and 0 for + ``print(msg, file=sys.stderr)`` — measured on @skills' module_paths.py and + @flow's import fence, 2026-08-31. Same stream, same bytes, 100 points apart + on nothing but which call the author reached for. + + Handler separation exists so a handler does not DISPLAY, and display is + stdout: the channel a router's output travels on. stderr is where a + diagnostic goes precisely so it does not pollute that channel, which is why + the raw-write form was never flagged. @flow reported it from the hardest + case — the cross-branch import fence, which runs before any logger in the + branch exists, being told to "use logger instead". + + The stream must be NAMED. A bare ``print()`` is display, ``file=sys.stdout`` + is display said out loud, and a print into some other file handle is a write + nobody is watching — the same qualification ``exception_handling``'s report + clause needs for ``.write``. + + Args: + content: Module source. + + Returns: + 1-indexed line numbers of stderr-directed print calls. Empty when the + source will not parse: an exemption bought with a SyntaxError is an + exemption granted on ignorance. + + Note: + The ``ast.Name`` test on the callee is an EQUIVALENT MUTANT — widening + it to match ``obj.print(...)`` changes no answer, because the caller + only ever subtracts from lines the ``^\s*print\s*\(`` regex already + matched, and a dotted call never starts a line that way. Recorded here + (mutation run 2026-08-31, survived) so nobody hunts for the pin that + does not exist. It stays because the function claims to find print + calls and should mean the builtin. + """ + try: + tree = ast.parse(content) + except SyntaxError: + return set() + + lines = set() + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "print"): + continue + for keyword in node.keywords: + if keyword.arg != "file": + continue + target = keyword.value + if isinstance(target, ast.Attribute) and target.attr == "stderr": + lines.add(node.lineno) + return lines + + def check_handler_separation(content: str) -> Dict: """ Check that handlers don't have console output @@ -179,6 +337,7 @@ def check_handler_separation(content: str) -> Dict: Excludes: if __name__ == '__main__': blocks (test/debug code is OK) """ lines = content.split("\n") + stderr_prints = _stderr_directed_print_lines(content) # Find code section boundaries (skip docstrings and comments) in_docstring = False @@ -243,8 +402,9 @@ def check_handler_separation(content: str) -> Dict: # This is likely an actual import cli_import_lines.append(i) - # Look for print() calls - if re.search(r"^\s*print\s*\(", line): + # Look for print() calls. A print DIRECTED AT stderr is a diagnostic, + # not display, and the raw-write spelling of it has always passed. + if re.search(r"^\s*print\s*\(", line) and i not in stderr_prints: # This is an actual print call at start of line (not in string) print_lines.append(i) @@ -336,6 +496,7 @@ def check_print_usage( bypass_rules: Optional bypass rules from .seedgo/bypass.json """ # Find print() statements and raw stdout/stderr writes + console_line = _console_import_line(content) print_lines = [] parser_print_help_lines = [] format_help_lines = [] @@ -391,14 +552,11 @@ def check_print_usage( else: format_help_lines.append(i) - # Check for raw sys.stdout.write() / sys.stderr.write() (bypasses Rich) - if "sys.stdout.write(" in stripped or "sys.stderr.write(" in stripped: - if "#" in line: - code_part = line.split("#")[0] - if "sys.stdout.write(" in code_part or "sys.stderr.write(" in code_part: - raw_write_lines.append(i) - else: - raw_write_lines.append(i) + # Raw sys.stdout/stderr.write() — bypasses Rich, EXCEPT when it is a + # verbatim relay of a captured subprocess stream (see the helper), or + # when it sits in the window before this module's own console exists. + if _is_raw_write_violation(line) and not _is_pre_console_bootstrap(i, console_line): + raw_write_lines.append(i) # Use regex to find BARE print() - not preceded by . or word character # This excludes: console.print(), logger.print(), pprint(), etc. diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_content.py index a260a4b37..1d3fa032c 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_content.py @@ -76,6 +76,47 @@ def get_cli_standards() -> str: " [dim]src/aipass/seedgo/apps/standards/aipass/modules/ (standards modules)[/dim]", " Shows CLI service usage with error handling", "", + "[bold cyan]EXEMPTION -- RELAYING A CAPTURED SUBPROCESS STREAM:[/bold cyan]", + " sys.stdout.write() is ALLOWED when the argument is a completed", + " subprocess's captured stream AND the stream names correspond:", + " [green]sys.stdout.write(result.stdout)[/green] relay -- allowed", + " [green]sys.stderr.write(result['stderr'])[/green] relay -- allowed", + " [red]sys.stdout.write('hello')[/red] your own output -- use Rich", + " [red]sys.stdout.write(f'{n} done')[/red] your own output -- use Rich", + " [red]sys.stdout.write(result.stderr)[/red] CROSSED streams -- a routing bug", + " [dim]Why: this rule means route YOUR OWN output through Rich. A router[/dim]", + " [dim]relays a child's bytes verbatim, and console.print would interpret[/dim]", + " [dim]markup in, wrap and re-style output it never authored.[/dim]", + " [yellow]![/yellow] Keyed on the ARGUMENT, so it cannot be borrowed by writing", + " [dim]your own string. Crossed streams stay a violation on purpose.[/dim]", + "", + "[bold yellow]EXCEPTION 2 -- the pre-console bootstrap window:[/bold yellow]", + " A raw stream write is ALLOWED when it lexically PRECEDES the module's", + " own console import. Before that import has run the console does not", + " exist, so the rule is unsatisfiable and not merely inconvenient:", + " [green]sys.stderr.write('sys.path repair failed')[/green] line 12 -- before", + " [dim]from aipass.cli... import console[/dim] line 20", + " [red]sys.stderr.write('later failure')[/red] line 40 -- after", + " [yellow]![/yellow] STRICTLY prior. A write sharing the import's own statement", + " [dim]comes after it has run, so the console exists and Rich applies.[/dim]", + " [dim]Reported by @commons from an entry point's sys.path repair, where[/dim]", + " [dim]this rule and silent_catch were mutually unsatisfiable (2026-08-31).[/dim]", + "", + "[bold yellow]HANDLERS: the rule reads the STREAM, not the spelling:[/bold yellow]", + " Handler separation exists so a handler does not DISPLAY, and display", + " is stdout -- the channel a router's output travels on. stderr is where", + " a diagnostic goes so it does NOT pollute that channel:", + " [green]print(msg, file=sys.stderr)[/green] a diagnostic -- allowed", + " [green]sys.stderr.write(msg)[/green] the same thing -- always was", + " [red]print(msg)[/red] display -- use a logger", + " [red]print(msg, file=sys.stdout)[/red] display, said out loud", + " [red]print(msg, file=some_handle)[/red] a write nobody is watching", + " [yellow]![/yellow] Corrected because the checker already passed the write form", + " [dim]and failed the print form: same stream, same bytes, 100 points[/dim]", + " [dim]apart on nothing but which call the author reached for. Found by[/dim]", + " [dim]@flow from the cross-branch import fence -- the one file in a[/dim]", + " [dim]branch that runs before any logger exists, being told to use one.[/dim]", + "", "[bold cyan]REFERENCE:[/bold cyan]", " [dim]See: seedgo standards pack (cli)[/dim]", " [dim]See: src/aipass/cli/ (CLI service provider)[/dim]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/documentation_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/documentation_check.py index 986d21fcc..377026520 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/documentation_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/documentation_check.py @@ -13,6 +13,7 @@ META block validation is handled separately by meta_check.py. """ +import ast import re from pathlib import Path from typing import Dict, List @@ -101,22 +102,152 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: return {"passed": overall_passed, "checks": checks, "score": score, "standard": "DOCUMENTATION"} -def check_module_docstring(lines: List[str]) -> Dict: - """ - Check for module-level docstring. +#: The two triple-quote spellings, named because the fallback below is a +#: LINE SCAN and a literal quote in this file's own source is exactly the +#: construct that makes such a scan hard to read. +TRIPLE_DOUBLE: str = chr(34) * 3 +TRIPLE_SINGLE: str = chr(39) * 3 + + +def _module_docstring_by_line_scan(lines: List[str]) -> Dict: + """The pre-AST scan, kept for files that will not parse. - Looks for a triple-quoted string near the top of the file, - allowing for META block, comments, or blank lines before it. + Says so in the message, because a fallback that reads like a verdict from + the real arm is an exemption granted on ignorance wearing a clean result. + + Args: + lines: The module's source lines. + + Returns: + dict with name, passed, message keys """ for line in lines[:30]: stripped = line.strip() - if stripped.startswith('"""') or stripped.startswith("'''"): - return {"name": "Module docstring", "passed": True, "message": "Module-level docstring present"} + if stripped.startswith(TRIPLE_DOUBLE) or stripped.startswith(TRIPLE_SINGLE): + return { + "name": "Module docstring", + "passed": True, + "message": "Module-level docstring present (file does not parse - line scan)", + } + return { + "name": "Module docstring", + "passed": False, + "message": "Missing module-level docstring (file does not parse - line scan)", + } + + +def check_module_docstring(lines: List[str]) -> Dict: + r""" + Check for module-level docstring, read as Python reads it. + + THE LINE SCAN THIS REPLACES WAS DECIDED BY SPELLING, in both directions, and + both were measured across 1,845 files before this changed: + + - it MISSED a prefixed docstring. An r-prefixed triple-quoted string is a + module docstring to Python and to help(); to a scan for a line starting + with a quote character it is nothing. Found by auditing a file of my own + that had one. + - it CREDITED any triple-quoted string in the first 30 lines - a class or + function docstring on a short module, a multi-line constant - to a file + with no module docstring at all. Seven files fleet-wide, and this is the + dangerous direction: a false negative gets believed. + + ast.get_docstring asks the only question that matters, which is whether the + first statement in the module body IS a string. + + Args: + lines: The module's source lines. + + Returns: + dict with name, passed, message keys + """ + try: + tree = ast.parse("\n".join(lines)) + except SyntaxError as exc: + # Logged rather than swallowed, and my own silent_catch rule is the + # reason: it accepts a bare `return None` here and flags a return that + # NAMES its fallback, which rewards the less informative spelling. That + # is the same defect I fixed in the classify-and-return clause tonight, + # one clause over - banked as a rule question rather than widened + # mid-train, because "any call return converts the exception" would gut + # a rule that exists to catch real swallows. + logger.info("Module docstring check fell back to a line scan: %s", exc) + return _module_docstring_by_line_scan(lines) + + if ast.get_docstring(tree) is not None: + return {"name": "Module docstring", "passed": True, "message": "Module-level docstring present"} return { "name": "Module docstring", "passed": False, - "message": "Missing module-level docstring (expected within first 30 lines)", + "message": "Missing module-level docstring (no string literal opens the module body)", + } + + +def _public_functions_by_ast(content: str): + """Public functions and whether each has a docstring, read as Python reads. + + THE LINE SCAN BELOW READ STRINGS AS SOURCE. ``stripped.startswith("def ")`` + over the raw file matches a ``def`` inside a docstring's code example or + inside a module built as a string literal and fed to a subprocess — the + shape every branch wrote this week for the dead-cwd worlds. @api, @canary, + @skills and two seedgo files were all flagged for functions that do not + exist. Caught by dogfooding: this checker flagged a three-line example in a + new nominator's own docstring. + + It had the mirror defect too. ``async def`` never matched at all, so an + undocumented async function was invisible — ``find_import_section_end`` in + the imports pack has handled ``async def`` since it was written. + + Scope is UNCHANGED: nested functions still count, exactly as the line scan + counted them. Narrowing to module and class level would clear 40 more files + and that is a different decision, not this fix. + + Args: + content: Module source. + + Returns: + A list of ``(name, line, has_docstring)`` triples, or None when the + source will not parse. + """ + try: + tree = ast.parse(content) + except SyntaxError: + return None + + found = [] + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if node.name.startswith("_"): + continue + found.append((node.name, node.lineno, bool(ast.get_docstring(node)))) + return found + + +def _judge(found: list) -> Dict: + """Turn the parsed function list into this check's verdict. + + Args: + found: ``(name, line, has_docstring)`` triples. + + Returns: + The check result dict. + """ + if not found: + return {"name": "Function docstrings", "passed": True, "message": "No public functions to check"} + + undocumented = [f"{name} (line {line})" for name, line, documented in found if not documented] + if undocumented: + return { + "name": "Function docstrings", + "passed": False, + "message": f"{len(undocumented)} public functions missing docstrings: {undocumented[0]}", + } + return { + "name": "Function docstrings", + "passed": True, + "message": f"All {len(found)} public functions have docstrings", } @@ -126,6 +257,14 @@ def check_function_docstrings(content: str, lines: List[str]) -> Dict: # noqa: Public functions (not starting with _) should have docstrings. """ + parsed = _public_functions_by_ast(content) + if parsed is not None: + return _judge(parsed) + + # AST unavailable (the file will not parse). Fall back to the line scan + # rather than reporting clean: a file we could not read has not been proven + # to document anything, and an exemption bought with a SyntaxError is an + # exemption granted on ignorance. public_functions = [] for i, line in enumerate(lines, 1): stripped = line.strip() diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py index a6b410d73..5af453269 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py @@ -16,8 +16,10 @@ - Handler security guards present in handlers/__init__.py (inspect.stack guard) """ +import ast import re import json +from functools import lru_cache from pathlib import Path from typing import Dict, List, Optional @@ -461,6 +463,80 @@ def check_cross_branch_imports( } +#: Handlers every module may import by name, because a branch cannot function +#: without them. Kept as NAMES only for the two that were always here; anything +#: else must EARN the exemption structurally — see :func:`_infrastructure_handlers`. +_DEFAULT_HANDLERS = frozenset({"json_handler", "file_handler"}) + + +@lru_cache(maxsize=32) +def _infrastructure_handlers(branch_root_str: str) -> frozenset: + """Handler modules the branch's OWN json_handler depends on. + + THE ARGUMENT, and it is why this is not just a longer allow-list: if + json_handler may be imported anywhere — which this checker has always said — + then so may anything json_handler itself imports, because that module is + BENEATH json_handler in the branch's own import order. Nothing can use + json_handler without already having loaded it. + + WHERE IT CAME FROM. @daemon, 2026-08-31, two adjacent lines in their entry + point: the json_handler import passed and the module_root import scored the + file 66%. Both are same-branch handler imports by the branch's own entry + point, and the handlers guard itself permits exactly this — it blocks + CROSS-branch imports. The checker was exempting json_handler by NAME rather + than by the property that makes it fine. + + module_root (spellings vary: paths.py, module_paths.py, repo_root.py) is the + fleet-ratified cure for the Windows dead-cwd defect, adopted by ten-plus + branches this round, and it is a handler BY CONSTRUCTION — it has to sit + under handlers/ to be the one guarded spelling. Measured 2026-08-31: 8 of 9 + branches carrying the cure have their json_handler importing it, so the + derived set finds it without anyone naming a file. + + Args: + branch_root_str: The branch directory, e.g. ``.../src/aipass/daemon``. + + Returns: + Module basenames (no extension) importable anywhere in the branch. + """ + branch_root = Path(branch_root_str) + handlers_root = branch_root / "apps" / "handlers" + sources = list(handlers_root.glob("*/json_handler.py")) + names: set = set() + for source in sources: + try: + tree = ast.parse(source.read_text(encoding="utf-8")) + except (OSError, SyntaxError) as exc: + # Unreadable is not evidence: the branch keeps the ordinary rule. + logger.info("encapsulation: cannot read %s for the infrastructure set: %s", source, exc) + continue + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + # Relative and absolute alike — @canary reaches theirs with + # ``from ..paths import module_file`` and it counts the same. + module = node.module or "" + candidates = [module.rsplit(".", 1)[-1]] if module else [] + candidates += [alias.name for alias in node.names] + names.update(c for c in candidates if c and (handlers_root / f"{c}.py").is_file()) + return frozenset(names) + + +def _branch_root(module_path: str) -> Optional[Path]: + """The ``src/aipass/`` directory above a file, or None.""" + for parent in Path(module_path).resolve().parents: + if parent.parent.name == "aipass" and (parent / "apps").is_dir(): + return parent + return None + + +def _allowed_handler_names(module_path: str) -> frozenset: + """Default handlers plus whatever this branch's json_handler stands on.""" + branch_root = _branch_root(module_path) + if branch_root is None: + return _DEFAULT_HANDLERS + return _DEFAULT_HANDLERS | _infrastructure_handlers(str(branch_root)) + + def check_cross_package_imports( lines: List[str], module_path: str, file_package: str, bypass_rules: list | None = None ) -> Dict: @@ -477,7 +553,7 @@ def check_cross_package_imports( violations = [] # Allowed default handlers that can be imported anywhere - allowed_handlers = {"json_handler", "file_handler"} + allowed_handlers = _allowed_handler_names(module_path) in_docstring = False for i, line in enumerate(lines, 1): @@ -590,7 +666,7 @@ def check_direct_handler_imports(lines: List[str], module_path: str, bypass_rule violations = [] # Allowed default handlers - allowed_handlers = {"json_handler", "file_handler"} + allowed_handlers = _allowed_handler_names(module_path) in_docstring = False for i, line in enumerate(lines, 1): diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/error_handling_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/error_handling_check.py index 6fed92825..94307dbd0 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/error_handling_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/error_handling_check.py @@ -18,6 +18,7 @@ from typing import Dict, List, Optional from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers.aipass_standards import exception_handling from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed # Audit scope: all Python files @@ -85,7 +86,7 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: return {"passed": overall_passed, "checks": checks, "score": score, "standard": "ERROR_HANDLING"} -def _silent_except_lines(content: str) -> Optional[List[int]]: +def _silent_except_lines(content: str, module_path: str = "") -> Optional[List[int]]: """Return the 1-indexed lines of every `except ...:` whose body is only `pass`. Returns None when the file cannot be parsed, so the caller can skip rather @@ -106,11 +107,23 @@ def _silent_except_lines(content: str) -> Optional[List[int]]: logger.info("Skipping error-handling scan: SyntaxError during parse: %s", e) return None - return [ - node.lineno - for node in ast.walk(tree) - if isinstance(node, ast.ExceptHandler) and node.body and all(isinstance(s, ast.Pass) for s in node.body) - ] + # Walked as Try nodes so the diagnostic-guard clause can read the BLOCK a + # handler protects; an ExceptHandler has no link back to its own try. + silent = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Try): + continue + for handler in node.handlers: + if not handler.body or not all(isinstance(s, ast.Pass) for s in handler.body): + continue + # A `pass` guarding a block that does nothing but REPORT is catching + # the failure of the report, not the original error — the same + # clause silent_catch uses, shared so the two cannot drift + # (@canary and @daemon, 2026-08-31). + if exception_handling.guards_a_diagnostic(node, module_path, handler.lineno): + continue + silent.append(handler.lineno) + return sorted(silent) def check_error_handling(content: str, lines: List[str], module_path: str = "") -> Optional[Dict]: @@ -120,7 +133,7 @@ def check_error_handling(content: str, lines: List[str], module_path: str = "") if try_count == 0: return None - silent_lines = _silent_except_lines(content) + silent_lines = _silent_except_lines(content, module_path) if silent_lines is None: return None diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/exception_handling.py b/src/aipass/seedgo/apps/handlers/aipass_standards/exception_handling.py new file mode 100644 index 000000000..750569b81 --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/exception_handling.py @@ -0,0 +1,204 @@ +# =================== AIPass ==================== +# Name: exception_handling.py +# Description: Shared discriminators for handlers that report rather than swallow +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""What separates a handler that SWALLOWS from one that reports. + +Two checkers ask overlapping questions about ``except`` blocks — ``silent_catch`` +("no logger call, no re-raise") and ``error_handling`` ("except: pass") — and +both convicted the same shape this morning, so the discriminators live here once +rather than drifting apart in two files. + +WHERE THIS CAME FROM. The fleet spent this week adopting one guarded spelling for +module-level ``__file__`` resolution (the Windows dead-cwd cure). Every branch's +copy has the same two-layer shape, and BOTH layers looked silent to a checker +reading only for a logger call: + + try: + return path.resolve() + except OSError as exc: # <- hands the exception to a named reporter + _record_unresolved(path, exc) + return path + + def _record_unresolved(path, exc): + try: + logger.debug(...) # the diagnostic + except Exception as inner: # <- guards the diagnostic itself + _retain("logger", inner) + +@daemon reported it with the measurement that makes it undeniable: their first +cut logged from OUTSIDE the try, and their own pin caught it. The world that +reaches this code is a machine whose filesystem cannot answer a basic question, +and @prax's logger construction reads the working directory — so "the logger is +also down" is the SAME world, not a contrived one. A guard that dies in its own +diagnostic converts a survivable import into a crash while claiming to prevent +exactly that. The literal fix the checker demanded was to add a logger call to +the handler for a logger failure: the defect their pin had just caught, +reintroduced to satisfy a score. + +@canary reported the same site from the other side, having MEASURED the +prescription rather than argued with it — applying it verbatim made their branch +unimportable — and @backup, @skills and @trigger carry the identical shape. +""" + +import ast +from typing import List + +from aipass.seedgo.apps.handlers.json import json_handler + +#: Attribute names that ARE a report. ``write`` is qualified separately: only a +#: write to a standard stream reports, and a write to a file handle nobody is +#: watching does not. +_REPORT_ATTRS = frozenset({"debug", "info", "warning", "error", "critical", "exception", "log_operation"}) + +#: Container mutations that may sit beside a report without changing what the +#: block is FOR — deduping a repeated message, appending to a bounded list. +_BOOKKEEPING_ATTRS = frozenset({"add", "append", "discard", "remove", "extend"}) + + +def _statement_calls(nodes: List[ast.stmt]) -> List[ast.Call]: + """Calls made as STATEMENTS, not calls nested inside another call's arguments. + + The distinction is load-bearing and was found by measuring: a report almost + always composes its message with ``type(exc).__name__`` or ``str(exc)``, and + counting those as effects made every real report look impure. The first cut + missed @canary's site for exactly that reason. + + Args: + nodes: Statements to walk. + + Returns: + Every ``ast.Call`` appearing as an expression statement. + """ + calls = [] + for node in nodes: + for sub in ast.walk(node): + if isinstance(sub, ast.Expr) and isinstance(sub.value, ast.Call): + calls.append(sub.value) + return calls + + +def _is_report_call(call: ast.Call) -> bool: + """True for a call whose whole purpose is to tell someone what happened.""" + func = call.func + if not isinstance(func, ast.Attribute): + return False + if func.attr == "write": + stream = func.value + return isinstance(stream, ast.Attribute) and stream.attr in ("stdout", "stderr") + return func.attr in _REPORT_ATTRS + + +def _is_bookkeeping_call(call: ast.Call) -> bool: + """True for a container mutation that supports a report without being one.""" + func = call.func + return isinstance(func, ast.Attribute) and func.attr in _BOOKKEEPING_ATTRS + + +def _record_grant(clause: str, module_path: str, line: int) -> None: + """Log every exemption this module hands out. + + An exemption that fires silently is the thing I have spent this week arguing + against: a reader seeing a 100 cannot tell "clean" from "excused". These are + rare by construction — 16 handlers in the whole fleet — so the audit trail + costs nothing and answers "why is this file green" without reading the + checker. + + Args: + clause: Which discriminator granted it. + module_path: File the handler lives in, when the caller knows it. + line: 1-indexed line of the handler. + """ + json_handler.log_operation( + "exception_handling_exemption_granted", + {"clause": clause, "file": module_path or "unknown", "line": line}, + ) + + +def guards_a_diagnostic(try_node: ast.Try, module_path: str = "", line: int = 0) -> bool: + """True when this ``try`` block does nothing but REPORT. + + A handler attached to such a block is catching the failure OF THE REPORT. + The original error was already dealt with by whatever produced the report, + so nothing is being swallowed — and demanding a log call here asks the + diagnostic to be protected by a second diagnostic, forever. + + Requires at least one genuine report: an empty ``try`` or one holding only + bookkeeping is not a diagnostic and keeps its finding. + + MEASURED before landing, 2026-08-31: 6 handlers in the fleet match, in 6 + different branches, and every one is the dead-cwd cure's own diagnostic + guard — @backup, @canary, @daemon (x2), @skills, @trigger. Exactly the + reported class, nothing else. + + Args: + try_node: The ``try`` statement whose handler is being judged. + module_path: File being checked, recorded when an exemption is granted. + line: Handler line, recorded with the grant; defaults to the try's own. + + Returns: + True if every statement-level call in the block reports or is + bookkeeping, and at least one reports. + """ + calls = _statement_calls(try_node.body) + if not any(_is_report_call(call) for call in calls): + return False + if not all(_is_report_call(call) or _is_bookkeeping_call(call) for call in calls): + return False + _record_grant("guards_a_diagnostic", module_path, line or try_node.lineno) + return True + + +def hands_the_exception_on(handler: ast.ExceptHandler, module_path: str = "") -> bool: + """True when the caught exception LEAVES the handler as a value. + + ``except OSError as exc: _record_unresolved(path, exc)`` does not drop + anything — the exception is handed to a named function that owns reporting + it. This is @spawn's classify-and-return one level out: there the + information became a return value, here it becomes an argument. In both the + caller decides, which is the opposite of swallowing. + + The clause is on the EXCEPTION NAME, so it cannot be borrowed by a handler + that merely calls something: a handler that never binds ``as`` has no + exception object to pass, and one that binds it and ignores it stays red. + The checker cannot follow the callee, and does not claim to — what it + verifies is that the object was not dropped on the floor. + + MEASURED before landing, 2026-08-31: 10 handlers match across the fleet and + all 10 were read individually. Six are the dead-cwd cure delegating to a + named reporter (@backup, @daemon x3, @skills, @trigger); the other four are + a Rich console report before ``sys.exit`` (@cli), a subprocess protocol + writing its error as JSON on stdout (@memory), a deliberately rate-limited + queue warning (@prax), and a retained-unresolved list (@trigger). Zero + looked like a swallow. + + Args: + handler: The except handler being judged. + module_path: File being checked, recorded when an exemption is granted. + + Returns: + True if the bound exception name appears in a call's arguments. + + Note: + The ``handler.name`` guard below is an EQUIVALENT MUTANT — removing it + changes no answer, because an unbound handler has ``name is None`` and + no ``ast.Name`` ever compares equal to it. It is kept for the reader and + recorded here so nobody hunts for the pin that does not exist. Mutation + run 2026-08-31: survived, and it should have. + """ + if not handler.name: + return False + for node in ast.walk(handler): + if not isinstance(node, ast.Call): + continue + arguments = [*node.args, *(keyword.value for keyword in node.keywords)] + for argument in arguments: + for sub in ast.walk(argument): + if isinstance(sub, ast.Name) and sub.id == handler.name: + _record_grant("hands_the_exception_on", module_path, handler.lineno) + return True + return False diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py index c81d421ab..6c6e70e12 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py @@ -265,35 +265,223 @@ def check_handler_independence(content: str, lines: List[str], module_path: str) return {"name": "Handler independence", "passed": True, "message": "No forbidden cross-branch handler imports"} -def check_auto_detection(content: str) -> Optional[Dict]: +def _public_function_takes_module_name(content: str) -> bool: + """True when a PUBLIC function accepts a ``module_name`` parameter. + + Auto-detection exists so a CALLER does not have to name itself: log_operation( + operation, data, module_name=None) is the shape, and it is part of a handler's + published surface. A private helper's parameter is internal plumbing — my own + _module_file(module_name, source_root) resolves a dotted name it was given and + has no caller to detect, and the regex that used to answer this question + convicted it on the parameter NAME alone (found by dogfooding, 2026-08-31). + + The leading underscore is the discriminator because it is a real, visible + property of the module's surface, not a spelling: renaming a public function + to buy the exemption removes it from the API. + + An unparseable file falls back to the old text scan rather than answering no — + a file we could not read is not evidence that the parameter is absent. """ - Check for auto-detection pattern if handler accepts module_name + try: + tree = ast.parse(content) + except SyntaxError as exc: + logger.info("handlers: unparseable, falling back to the text scan for module_name: %s", exc) + return bool(re.search(r"def\s+\w+\([^)]*module_name", content)) + + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if node.name.startswith("_"): + continue + args = node.args + names = [a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)] + if args.vararg: + names.append(args.vararg.arg) + if args.kwarg: + names.append(args.kwarg.arg) + if "module_name" in names: + return True + return False + + +#: Every way a handler can ask "who called me". The point of the SET is that the +#: standard's question is whether the caller is detected, never which library +#: spells it — see :func:`_walks_the_caller_frame`. +_FRAME_WALK_CALLS = frozenset( + { + "sys._getframe", + "inspect.currentframe", + "inspect.stack", + "inspect.getouterframes", + "traceback.extract_stack", + "traceback.walk_stack", + } +) + +#: Helper names that ARE the auto-detection, whatever they use inside. +_CALLER_HELPER_MARKERS = ("_get_caller_module_name", "get_caller") + + +def _module_name_is_optional(content: str) -> bool: + """True when some public function lets ``module_name`` be omitted. + + AUTO-DETECTION ONLY MEANS SOMETHING IF THE ARGUMENT CAN BE MISSING. A + required ``module_name`` is supplied by every caller at every call site; + there is nothing to detect, and demanding a frame walk there asks for code + that can never run. - If handler has module_name parameter, should use inspect.stack() auto-detection + Measured 2026-08-31 across the fleet's 19 handler files with a public + ``module_name``: 3 take it as REQUIRED and were red for a cure that would be + dead code (@prax's logging/lifecycle.py, logging/setup.py and + terminal/filtering.py). 16 take it optionally, which is the population this + standard is actually about. + + Args: + content: File source. + + Returns: + True if any public function accepts ``module_name`` with a default, + or absorbs it through ``*args`` / ``**kwargs``. """ - # Check if any function accepts module_name parameter - has_module_name_param = bool(re.search(r"def\s+\w+\([^)]*module_name", content)) + try: + tree = ast.parse(content) + except SyntaxError as exc: + # Unreadable is not evidence of absence — same rule as the scan above. + logger.info("handlers: unparseable, assuming module_name is optional: %s", exc) + return True + + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) or node.name.startswith("_"): + continue + args = node.args + positional = [*args.posonlyargs, *args.args] + first_defaulted = len(positional) - len(args.defaults) + for index, arg in enumerate(positional): + if arg.arg == "module_name" and index >= first_defaulted: + return True + for arg, default in zip(args.kwonlyargs, args.kw_defaults): + if arg.arg == "module_name" and default is not None: + return True + if (args.vararg and args.vararg.arg == "module_name") or (args.kwarg and args.kwarg.arg == "module_name"): + return True + return False + + +def _walks_the_caller_frame(content: str) -> bool: + """True when the file asks who called it, BY ANY MECHANISM. + + THE CHECK USED TO MANDATE THE DEFECT. It accepted the literal string + ``inspect.stack()`` as its proof, and told every failing handler to "use + inspect.stack()". That call is the Windows dead-cwd defect the fleet spent + this week removing: it reaches an unguarded ``os.path.realpath`` inside + ``inspect.getmodule``, and ``ntpath.realpath`` reads ``os.getcwd()`` before + checking anything. @prax cured their own site and the audit dropped the file + to 66% and told them to put it back (reported 2026-08-31); they renamed a + helper rather than restore the call, which fixed prax and left the checker + pointed at the next branch to cure. + + So the acceptance is on the QUESTION, not the spelling. ``sys._getframe`` is + the sound answer — seedgo's own json_handler has used it since the day + inspect.stack() made audits slow — and it is what the failure message now + names. + + AST, never a substring, and that matters in BOTH directions: the old text + scan passed a file whose only ``inspect.stack()`` sat in a docstring or a + comment. @drone hit the mirror image the same morning — a text BAN convicting + the docstring that explained the cure. A string rule is too broad and too + narrow at once. + + Args: + content: File source. - if not has_module_name_param: + Returns: + True if a call to any known frame-walk API, or to a named caller + helper, appears anywhere in the file's parse tree. + """ + try: + tree = ast.parse(content) + except SyntaxError as exc: + logger.info("handlers: unparseable, cannot see a frame walk: %s", exc) + return False + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and any( + marker in node.name for marker in _CALLER_HELPER_MARKERS + ): + return True + if not isinstance(node, ast.Call): + continue + dotted = _dotted_call_name(node.func) + if dotted in _FRAME_WALK_CALLS or any(marker in dotted for marker in _CALLER_HELPER_MARKERS): + return True + return False + + +def _dotted_call_name(func: ast.expr) -> str: + """Dotted spelling of a call target, or "" when it is not a plain name. + + Args: + func: The ``func`` of an ``ast.Call``. + + Returns: + e.g. ``"sys._getframe"``, ``"inspect.stack"``, ``"log_operation"``. + """ + parts = [] + while isinstance(func, ast.Attribute): + parts.append(func.attr) + func = func.value + if isinstance(func, ast.Name): + parts.append(func.id) + return ".".join(reversed(parts)) + return "" + + +def check_auto_detection(content: str) -> Optional[Dict]: + """Check that a handler detects its caller when ``module_name`` is omissible. + + TWO CLAUSES, both AST and both measured before landing (2026-08-31): + 1. some PUBLIC function takes ``module_name`` and lets it be omitted — + a required parameter has nothing to detect; + 2. the file walks the caller frame by SOME mechanism, or carries a named + caller helper. + + WHAT THIS CHECK CANNOT SEE, named here rather than left for the next branch + to discover: it reads the parameter's NAME, so a ``module_name`` that is an + event PAYLOAD field rather than a caller identity looks identical to it. + @trigger's events/warning_logged.py is the live example — its + ``module_name`` is the module that logged a warning, carried in the event, + and its caller is the event bus. Auto-detecting there would be wrong. That + distinction is semantic and I have no structural measure for it, so it stays + a finding with its blind spot stated rather than a clause I cannot defend. + + Args: + content: File source. + + Returns: + A check dict, or None when the standard does not apply to this file. + """ + if not _public_function_takes_module_name(content): return None # No module_name parameter, auto-detection not needed - # Check for auto-detection implementation - has_inspect_import = "import inspect" in content - has_stack_usage = "inspect.stack()" in content - has_auto_detect_function = "_get_caller_module_name" in content or "get_caller" in content + if not _module_name_is_optional(content): + return None # Callers always supply it — there is nothing to detect - if has_auto_detect_function or (has_inspect_import and has_stack_usage): + if _walks_the_caller_frame(content): return { "name": "Auto-detection pattern", "passed": True, - "message": "Auto-detection pattern implemented (inspect.stack())", + "message": "Auto-detection pattern implemented (caller frame is read)", } - # Has module_name param but no auto-detection return { "name": "Auto-detection pattern", "passed": False, - "message": "Has module_name parameter but missing auto-detection (use inspect.stack())", + "message": ( + "Has an optional module_name but never reads the caller frame " + "(use sys._getframe — NOT inspect.stack(), which needs a readable cwd on Windows). " + "If this module_name is an event payload rather than a caller identity, say so — " + "the checker reads the name and cannot tell them apart." + ), } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/imports.md b/src/aipass/seedgo/apps/handlers/aipass_standards/imports.md index 7abfee4a3..4820ccfd5 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/imports.md +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/imports.md @@ -109,19 +109,139 @@ logger.warning("Memory file not found, using defaults") logger.error("Failed to backup branch", exc_info=True) ``` -**Both forms are valid:** +**All three forms are valid** — this standard is about **routing**, not binding style. +`@prax` ranked them **2026-08-30**; the ranking is theirs, this check accepts all three: ```python -# Canonical (full path) +# RECOMMENDED — @prax's ruling 2026-08-30. 385 files already use it; all three +# route identically, so consistency is the tiebreak. +from aipass.prax import logger + +# Full path — valid, but see the warning below: this is the one form that CRASHES +# under a mocked `aipass.prax`. from aipass.prax.apps.modules.logger import system_logger as logger -# Shorthand (via prax/__init__.py) -from aipass.prax import logger +# Module form — valid. Resolves `.logger` at call time (see the testability note), +# but that alone does not make it mockable. +from aipass import prax +... +prax.logger.debug("...") # Never these import aipass.prax.system_logger # ✗ import logging; logging.getLogger() # ✗ Use prax, not stdlib logging ``` +**Testability note — the recommended form is mockable by exactly one target.** The +object forms bind the logger **object** into the consumer's globals at import time. The +module form holds a *module* and resolves `.logger` at **call** time. + +That difference decides which mocking technique can reach a consumer. Measured against a +real consumer of the recommended form (`aipass/backup/.../json_handler.py:18`), by object +identity, with the real `prax` imported: + +| technique | reaches the consumer? | +|---|---| +| `patch("aipass.prax.logger")` | **no** | +| `patch("aipass.prax.apps.modules.logger.system_logger")` | **no** | +| `setitem(sys.modules, "aipass.prax", ...)` | **no** | +| `setitem(sys.modules, "aipass.prax.apps.modules.logger", ...)` | **no** | +| `patch(".logger")` | **yes** | + +Those first four are the four techniques actually in use across this fleet's conftests. +**All four reach nothing** — the consumer keeps the real `SystemLogger` for the whole +session and real writes escape into `@prax`'s live state directory. + +The fifth row is not "nobody", and @spawn corrected me on it (2026-08-31): they already +had exactly one correct call site — `tests/test_file_ops.py:176`, a `@patch` decorator +naming `file_ops.logger`, written before any of this came up. The technique was in the +fleet; it just was not in the *shared fixture*, which is where it mattered. Someone +arriving at the right answer without publishing it is the same failure mode as this +standard recommending a form without the technique that goes with it. + +The rule underneath is simpler than "rebindable vs not": **the last dot must be resolved +at call time.** Patching upstream of a binding never reaches past it — and note that +`aipass/prax/__init__.py` *itself* re-exports by binding (`from ...logger import +system_logger as logger`), so the freeze happens twice and the deeper targets miss too. + +**So if you use an object form — and 385 files do, against 0 using the module form — +mock it per consumer, by that consumer's own dotted path:** + +```python +patch("aipass..apps.handlers..logger") +``` + +Raised by **@memory 2026-08-30**, who proposed the module form as a fix, tested it, +found it insufficient alone, and mailed the correction before anyone spent a session on +it. Their fleet numbers (1552 writes in `@memory`) reproduced here: **one** `@daemon` +test under plain `pytest` performed **23 atomic writes into the real `prax_json/`**, +attributed by audit hook rather than inferred from a before/after diff — on a live +machine, ambient writes make that diff unattributable, and the control run drifted just +as much as the treatment. + +This standard does **not** rank the three forms. Which form is *recommended* is `@prax`'s +logging contract to set; the check accepts all three so that decision is never taken by +a checker rejecting one of them. + +**Warning — the full-path form crashes against a mocked `aipass.prax`.** If a fixture +puts a stand-in module in `sys.modules["aipass.prax"]`, the mock has no `apps` +submodule, so the deep import raises rather than resolving: + +``` +ModuleNotFoundError: No module named 'aipass.prax.apps'; 'aipass.prax' is not a package +``` + +The other two forms import cleanly under the same mock. Reported and measured by +`@prax` 2026-08-30, reproduced here. + +**The escaping writes are not the import form's fault.** `@prax` measured that +importing `prax` writes **nothing** in any of the three spellings — the writes come +from the **first call**, which starts the file watcher and auto-creates per-module +JSON (26 writes on call one, 0 on call two, identical across all three forms). The +actual cause is a **half-built seam in `prax` itself**: `config/load.py` already +redirects *log files* to `/tmp/aipass_test_logs/` when it detects pytest, but +`json_handler.PRAX_JSON_DIR` is a module-level constant built from `__file__` with no +pytest branch. Measured on one `logger.info()` under `PYTEST_CURRENT_TEST`: +**4 writes redirected, 24 writes into the real `prax_json/`.** `@prax` owns the fix +(APLAN-0009) and it needs no cooperation from callers. + +**A module-level env-var seam has to win an import race — and it usually loses.** +The same "captured at import" defect that freezes a logger binding also freezes a *path*. +This shape is wrong: + +```python +# ✗ resolved ONCE, at import time +DIR = Path(os.environ.get("AIPASS_TEST_LOG_DIR") or _DEFAULT) +``` + +It measures **green in isolation** — set the variable before anything imports the +module and it redirects correctly — and changes nothing in a real run, because a +`conftest.py` that sets the variable still loses to anything that imported the module +first. Reproduced here, same module, both orders: + +| when the env var is set | module-level constant | resolved at call time | +|---|---|---| +| before any import | `/tmp/redirected/x` | `/tmp/redirected/x` | +| **after something imported it** | `/real/live/tree/x` ❌ | `/tmp/redirected/x` ✅ | + +Resolve at **call** time instead: + +```python +# ✓ resolved per call — no import race to lose +def _dir() -> Path: + return Path(os.environ.get("AIPASS_TEST_LOG_DIR") or _DEFAULT) +``` + +Found by `@prax` 2026-08-30 while shipping the `PRAX_JSON_DIR` redirect (json_handler +1.2.0): their conftest set the variable before any import and the constant *still* +resolved to the live tree, because something imported `json_handler` first. Moving +resolution to call time took one `logger.info()` under pytest from **24 real writes to +0**, and prax's own collection from **107 atomic renames to 0**. + +`@prax` also reports the test-side half, which is the same lesson twice: a mutation +reverting the path builder to read the import-time constant **survived every test** +until the **use site** was pinned rather than the pure resolver. Testing a predicate is +not testing that anything calls it. + **Output location:** Prax manages log files, branches don't need to worry about where logs go --- diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/imports_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/imports_check.py index 7e325047f..aad1a71a0 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/imports_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/imports_check.py @@ -153,7 +153,19 @@ def _process_docstring_marker(stripped, in_docstring, docstring_marker): def filter_docstrings(lines: List[str]) -> List[str]: - """Filter out docstrings from lines to prevent false positives.""" + """Blank out docstring lines, PRESERVING every line's position. + + The blanking is not cosmetic. This function used to `continue` past + docstring lines, which COMPACTED the list — so every check that runs on the + result enumerated a shorter list and reported an index into it rather than a + line in the file. @memory was sent to line 40 for a violation at line 106 + (2026-08-30), and the defect was never this one check's: check_no_sys_path, + check_prax_logger, check_import_order and check_no_bare_imports all read the + same list and all carried the same off-by-however-many-docstring-lines. + + Every one of those checks already skips an empty line, so neutralising the + content while keeping the position costs nothing and fixes all five. + """ filtered_lines = [] in_docstring = False docstring_marker = None @@ -164,9 +176,11 @@ def filter_docstrings(lines: List[str]) -> List[str]: if '"""' in stripped or "'''" in stripped: skip, in_docstring, docstring_marker = _process_docstring_marker(stripped, in_docstring, docstring_marker) if skip: + filtered_lines.append("\n") continue if in_docstring: + filtered_lines.append("\n") continue filtered_lines.append(line) @@ -183,6 +197,16 @@ def find_import_section_end(lines: List[str]) -> int: return len(lines) +#: AIPASS_ROOT as a WHOLE TOKEN. A substring match convicted +#: `DECLARED_ROOTS = "AIPASS_ROOTS.json"` — a machine-managed filename that sits +#: beside AIPASS_REGISTRY.json and reads no environment variable of any name +#: (@memory, 2026-08-30). Every present and future `AIPASS_ROOT*` collided the +#: same way, so the fix is not a longer exception list. `_` is a word character, +#: so this also declines to match `MY_AIPASS_ROOT` and `AIPASS_ROOT_MAP`: the +#: check means the env var, and the env var is the only thing it may claim. +_AIPASS_ROOT_TOKEN = re.compile(r"\bAIPASS_ROOT\b") + + def check_no_aipass_root(lines: List[str], file_path: str = "", bypass_rules: list | None = None) -> Dict: """ Check that file does NOT use AIPASS_ROOT. @@ -196,11 +220,13 @@ def check_no_aipass_root(lines: List[str], file_path: str = "", bypass_rules: li if not stripped or stripped.startswith("#"): continue code_part = line.split("#")[0] if "#" in line else line - if "AIPASS_ROOT" in code_part: + if _AIPASS_ROOT_TOKEN.search(code_part): return { "name": "No AIPASS_ROOT", "passed": False, - "message": f"AIPASS_ROOT found on line {i} (pip packages must not use AIPASS_ROOT)", + "message": ( + f"AIPASS_ROOT found on line {i} (pip packages must not read the AIPASS_ROOT environment variable)" + ), } return {"name": "No AIPASS_ROOT", "passed": True, "message": "No AIPASS_ROOT usage (correct for pip packages)"} @@ -234,9 +260,32 @@ def check_no_sys_path(lines: List[str], file_path: str = "", bypass_rules: list def check_prax_logger(lines: List[str], file_path: str = "", bypass_rules: list | None = None) -> Optional[Dict]: - """ - Check for Prax logger import via aipass.prax namespace. - Pattern: from aipass.prax import logger + """Check that logging is routed through the `aipass.prax` namespace. + + BOTH BINDING FORMS PASS, and the reason is the standard's own intent: + + from aipass.prax import logger # binds the logger OBJECT + from aipass import prax # binds the MODULE; prax.logger.x() + + This standard is about ROUTING - that logging goes through prax rather than + round a side channel. Both forms route identically, so a check that accepts + only the first was enforcing a BINDING STYLE the standard never asked for - + narrower than the rule it speaks for, the same defect as the handlers check + that once forbade every apps.modules import. + + AND THE NARROWER FORM HAS A MEASURED COST. `from aipass.prax import logger` + binds the object at import time, so it is UNREBINDABLE: a conftest that + swaps sys.modules["aipass.prax"] in an autouse fixture - which is most of + this fleet - cannot reach a name already bound. Raised by @memory + 2026-08-30 with fleet numbers (1552 writes in @memory, 1968 in @daemon) and + reproduced here: ONE daemon test under plain pytest performed 23 atomic + writes into @prax's REAL prax_json/, attributed by audit hook rather than + inferred from a before/after diff, which a live machine's ambient writes + make unattributable. The module form is rebindable because the attribute is + looked up at CALL time. + + Which form is RECOMMENDED is @prax's contract to set, not this checker's. + This check no longer takes that decision by rejecting one of them. """ if is_bypassed(file_path, "imports", None, bypass_rules): return {"name": "Prax logger import", "passed": True, "message": "Bypassed by bypass rules"} @@ -245,16 +294,33 @@ def check_prax_logger(lines: List[str], file_path: str = "", bypass_rules: list stripped = line.strip() if not stripped or stripped.startswith("#"): continue - if "from aipass.prax" in line and "logger" in line: - return {"name": "Prax logger import", "passed": True, "message": f"Found on line {i}"} + if "from aipass.prax" in stripped and "logger" in stripped: + return {"name": "Prax logger import", "passed": True, "message": f"Found on line {i} (object form)"} + if _imports_prax_module(stripped): + return {"name": "Prax logger import", "passed": True, "message": f"Found on line {i} (module form)"} return { "name": "Prax logger import (recommended)", "passed": False, - "message": "Prax logger import not found (recommended: from aipass.prax import logger)", + "message": ( + "Prax logger import not found (either form: 'from aipass.prax import logger' or 'from aipass import prax')" + ), } +def _imports_prax_module(stripped: str) -> bool: + """True for `from aipass import prax`, the rebindable module binding. + + Matched on the comma-separated import LIST so `from aipass import cli` + never counts, and `from aipass import cli, prax` does. + """ + source, separator, imported = stripped.partition(" import ") + if not separator or source.strip() != "from aipass": + return False + names = imported.split("#")[0].split(",") + return "prax" in [name.strip().split(" as ")[0].strip() for name in names] + + def check_handler_independence( lines: List[str], module_path: str = "", bypass_rules: list | None = None ) -> Optional[Dict]: diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/imports_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/imports_content.py index ae32f1dd9..9c4e178c4 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/imports_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/imports_content.py @@ -93,6 +93,27 @@ def get_imports_standards() -> str: " [dim]src/aipass/seedgo/apps/standards/aipass/modules/ (reference modules)[/dim]", " [dim]src/aipass/cli/apps/modules/ (CLI service examples)[/dim]", "", + "[bold cyan]IMPORT-TIME CAPTURE -- THE REDIRECT SEAM:[/bold cyan]", + " A value captured at import cannot be redirected later. Resolve at CALL", + " time: [bold]the last dot must be resolved at call time[/bold] for a patch to reach", + " a consumer. [dim]import pkg; pkg.x() is patchable -- from pkg import x is not.[/dim]", + " [yellow]![/yellow] NECESSARY, NOT SUFFICIENT. If the call-time resolver compares", + " against a reference captured at import and derived from the SAME env", + " var, the seam is dead again -- the stale value looks like a deliberate", + " override and wins for the rest of the process.", + " [bold]A reference that is itself derived from the thing you are[/bold]", + " [bold]detecting cannot detect it.[/bold] [dim](@daemon via @prax, 2026-08-31)[/dim]", + " [yellow]![/yellow] EVIDENCE SHAPE: a per-branch green does not clear this class.", + " [dim]Pin it in a SUBPROCESS -- export the var, import, assert the anchor[/dim]", + " [dim]is the real tree. In-process the property is unobservable by[/dim]", + " [dim]construction: the import already happened. @prax's suite was green[/dim]", + " [dim]from the branch dir and red from the repo root, on import-order luck.[/dim]", + " [yellow]![/yellow] MOCKING A RE-EXPORTED OBJECT: patch the CONSUMING module's name", + ' [dim]-- patch(".logger"). Patching upstream of a binding[/dim]', + " [dim]never reaches past it, and aipass/prax/__init__ re-exports by[/dim]", + " [dim]binding, so the copy is taken twice. Five fleet techniques measured[/dim]", + " [dim]against a real consumer by object identity: all five reach nothing.[/dim]", + "", "[bold cyan]REFERENCE:[/bold cyan]", " [dim]See: seedgo standards pack (imports)[/dim]", "", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_check.py index 9f3e86898..7c957cbac 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_check.py @@ -21,7 +21,9 @@ Entry points and other files outside modules/handlers are skipped. """ +import ast import re +from functools import lru_cache from pathlib import Path from typing import Dict, List @@ -126,6 +128,43 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: "standard": "JSON STRUCTURE", } + # --- Declarations-only module: nothing to log, so nothing to ask --- + if (in_modules or in_handlers) and _declares_no_callable_code(content): + return { + "passed": True, + "checks": [ + { + "name": "JSON structure check", + "passed": True, + "message": ( + "Declarations only — no functions or methods, so the module performs " + "no operations to log (not applicable)" + ), + } + ], + "score": 100, + "standard": "JSON STRUCTURE", + } + + # --- Pre-logging bootstrap module: importing json_handler here is a cycle --- + if (in_modules or in_handlers) and _is_prelogging_bootstrap(path, content): + return { + "passed": True, + "checks": [ + { + "name": "JSON structure check", + "passed": True, + "message": ( + "Pre-logging bootstrap module — reached from the logging chain's own " + "imports and holds no aipass import of any kind, so json_handler cannot " + "be imported here without a cycle (not applicable)" + ), + } + ], + "score": 100, + "standard": "JSON STRUCTURE", + } + # --- Cases (b) and (c): code wiring check --- if in_modules or in_handlers: checks = _check_code_wiring(path, content) @@ -150,6 +189,238 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: # ------------------------------------------------------------------ +def _declares_no_callable_code(content: str) -> bool: + """True when a module defines no function or method ANYWHERE. + + "Every module/handler must log operations" is a rule about modules that + PERFORM operations. A file of pure exception or dataclass declarations + performs none, so the check was asking a question the file cannot answer — + and the only two ways to answer it were both worse than the violation + (@drone, 2026-08-31): restore a module-level log_operation, which is the + import-time-write defect that fires during pytest COLLECTION where no + fixture can intercept it; or add a function to a file of class definitions + solely so there is somewhere to call log_operation from. + + Deliberately keyed on callable code and nothing else. ONE method anywhere — + including in a class body — puts the module back in scope, because a method + is somewhere an operation can happen. Constants and dataclass fields do not + buy the exemption back; data is still declarations. + + An unparseable file is NOT exempt: a syntax error is not evidence of purity, + and answering "no callable code" for a file we could not read would hand out + the exemption on ignorance. + """ + try: + tree = ast.parse(content) + except SyntaxError as exc: + logger.info("json_structure: could not parse for the declarations-only test: %s", exc) + return False + return not any(isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) for node in ast.walk(tree)) + + +#: How many modules the bootstrap walk will visit before it gives up. The whole +#: fleet's logging chain measures ~100; this is a runaway guard, not a budget. +_BOOTSTRAP_WALK_CAP = 2000 + + +def _resolve_relative(module_name: str | None, level: int, package: str | None) -> str | None: + """Absolute dotted name for a relative import, given the importer's package. + + ``from ..paths import module_file`` inside + ``aipass.canary.apps.handlers.json.json_handler`` is + ``aipass.canary.apps.handlers.paths`` — a perfectly static fact the walk was + throwing away. + + Args: + module_name: The ``module`` of an ImportFrom, or None for ``from . import x``. + level: Number of leading dots. + package: Dotted package the importing file lives in, or None if unknown. + + Returns: + The absolute dotted name, or None when it cannot be resolved. + """ + if not package or level <= 0: + return None + parts = package.split(".") + if level - 1 > len(parts): + return None + base = parts[: len(parts) - (level - 1)] if level > 1 else parts + if not base: + return None + return ".".join(base + ([module_name] if module_name else [])) + + +def _aipass_imports(content: str, package: str | None = None) -> list[str]: + """Every ``aipass.*`` module name this source imports, at any nesting depth. + + Function-level imports count. A module that reaches aipass only from inside + a function has still taken the dependency, and could take json_handler the + same way. + + RELATIVE IMPORTS COUNT TOO, when the caller supplies ``package``. They did + not until 2026-08-31, and the hole was exactly where it hurt: @canary's + json_handler reaches its stdlib-only bootstrap helper by + ``from ..paths import module_file``, so ``paths.py`` never entered the + bootstrap chain, clause 2 of the exemption failed, and a module that IS + beneath the logging system was told to import it. A relative import is a + STATIC fact — unlike the importlib hops this walk deliberately cannot see — + so resolving it moves the set toward correct in the direction the walk's own + docstring already asks for. + + Args: + content: File source. + package: Dotted package of the importing file, for relative resolution. + None keeps the old absolute-only behaviour, which is what the + "holds no aipass import" clause wants for a file it cannot place. + + Returns: + Dotted ``aipass.*`` names, absolute. + """ + try: + tree = ast.parse(content) + except SyntaxError as exc: + logger.info("json_structure: could not parse for the bootstrap-chain walk: %s", exc) + return [] + names: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + if node.level == 0 and node.module: + names.append(node.module) + names.extend(f"{node.module}.{alias.name}" for alias in node.names) + continue + resolved = _resolve_relative(node.module, node.level, package) + if resolved: + names.append(resolved) + names.extend(f"{resolved}.{alias.name}" for alias in node.names) + return [name for name in names if name.split(".")[0] == "aipass"] + + +def _aipass_source_root(path: Path) -> Path | None: + """The ``src/aipass`` directory above this file, or None if it is not there. + + None means no exemption: a file we cannot place in the fleet's import graph + is measured by the ordinary rule. + """ + for parent in path.resolve().parents: + if parent.name == "aipass" and (parent / "prax").is_dir(): + return parent + return None + + +def _module_file(module_name: str, source_root: Path) -> Path | None: + """Resolve a dotted ``aipass.*`` name to the file it would import.""" + candidate = source_root.parent / Path(*module_name.split(".")) + if (candidate / "__init__.py").is_file(): + return candidate / "__init__.py" + flat = candidate.with_suffix(".py") + return flat if flat.is_file() else None + + +def _module_name(path: Path, source_root: Path) -> str | None: + """The dotted name a file is imported under, or None if it is outside the tree.""" + try: + relative = path.resolve().relative_to(source_root.parent) + except ValueError as exc: + logger.info("json_structure: file is outside the resolved source root: %s", exc) + return None + parts = list(relative.with_suffix("").parts) + if parts and parts[-1] == "__init__": + parts.pop() + return ".".join(parts) if parts else None + + +@lru_cache(maxsize=8) +def _bootstrap_chain(source_root_str: str) -> frozenset: + """Every module the logging substrate imports, transitively. + + Seeded from the system logger and from every branch's json_handler, because + those are the two things this standard tells a module to import. Anything + they reach is BENEATH them in the import order and cannot import them back + without a cycle. + + ANCESTOR PACKAGES ARE DELIBERATELY ABSENT, and the reason is measured rather + than assumed. Importing aipass.flow.apps.handlers.json.json_handler runs + aipass/flow/apps/handlers/__init__.py first — Python imports every parent + package before the leaf, and no import statement says so, so the AST cannot + see it (@flow's fence, 2026-08-31). Adding them is therefore FACTUALLY + right about execution order and still wrong for this exemption: the wide + form (walking each ancestor's own imports) carried the exemption out across + every branch's public API via drone's re-exporting __init__ and exempted 4 + stdlib-only helpers that have a working logger; the narrow form (record the + ancestor, do not walk it) changed 0 verdicts in 1040 files. A clause that + grants exemptions nobody needs today is a waiver waiting for a file to drift + into it. Execution order is not the property this exemption is for — being + unable to reach the logger is — and every fence in the fleet already passes + on its own. + + Statically walked, so a dynamic (importlib) hop inside the chain is invisible + and the set comes out SHORT. That direction is deliberate: a missed member is + measured by the ordinary rule and stays red, which is the state it is in + today. An over-long set would hand out exemptions. + """ + source_root = Path(source_root_str) + branches = [d.name for d in source_root.iterdir() if (d / "apps").is_dir()] + queue = ["aipass.prax.apps.modules.logger"] + queue += [f"aipass.{name}.apps.handlers.json.json_handler" for name in branches] + + seen: set = set() + while queue and len(seen) < _BOOTSTRAP_WALK_CAP: + module_name = queue.pop() + if module_name in seen: + continue + module_file = _module_file(module_name, source_root) + if module_file is None: + continue + seen.add(module_name) + # The importing module's PACKAGE, so its relative imports resolve. A + # package __init__ is its own package; a plain module's package is its + # parent. + package = module_name if module_file.name == "__init__.py" else module_name.rsplit(".", 1)[0] + try: + queue.extend(_aipass_imports(module_file.read_text(encoding="utf-8", errors="ignore"), package)) + except OSError as exc: + logger.info("json_structure: unreadable module in the bootstrap walk: %s", exc) + return frozenset(seen) + + +def _is_prelogging_bootstrap(path: Path, content: str) -> bool: + """True for a module that sits BENEATH the logging system it would have to call. + + Two clauses, both measured here and neither declared by the branch: + + 1. The module holds no aipass import of any kind. @prax pins exactly this + property with an AST test, because a diagnostic that needs the logger it + is being emitted from is a second crash wearing a diagnostic's clothes. + 2. The logging substrate's own imports reach it. This is what keeps the + exemption from being free: 79 modules in the fleet import nothing from + aipass, and 9 of them are in the chain. + + Clause 1 alone would exempt every stdlib-only helper in the fleet; clause 2 + alone would exempt ai_mail's whole dispatch stack, which the chain reaches + THROUGH json_handler and which logs perfectly well. Only the pair names the + class @prax reported (2026-08-31): log_operation() writes, so wiring it into + a module the logger imports puts a file write on the import path of every + branch — the ungateable write that fires during pytest COLLECTION. + """ + try: + ast.parse(content) + except SyntaxError as exc: + # An unreadable file is not a bootstrap module; it is a file we could not + # read. _aipass_imports() answers [] for it, which would otherwise read as + # "holds no aipass import" and buy the exemption on ignorance. + logger.info("json_structure: unparseable, so not eligible for the bootstrap exemption: %s", exc) + return False + if _aipass_imports(content): + return False + source_root = _aipass_source_root(path) + if source_root is None: + return False + module_name = _module_name(path, source_root) + return module_name is not None and module_name in _bootstrap_chain(str(source_root)) + + def _check_code_wiring(_path: Path, content: str) -> List[Dict]: """ Check that a module/handler file has the three-JSON wiring: diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_content.py index bfa8f6e73..6aa95afd1 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/json_structure_content.py @@ -217,6 +217,38 @@ def get_json_structure_standards() -> str: " [dim]and hand-edit. A trio file you hand-craft is a squatter and is flagged.[/dim]", " [yellow]![/yellow] Both import AND log_operation required for compliance", "", + "[bold cyan]EXEMPTION -- DECLARATION-ONLY MODULES:[/bold cyan]", + " A module defining NO functions or methods anywhere performs no", + " operations, so it has nothing to log. Reported not_applicable.", + " [dim]Why: @drone's handlers/exceptions.py (10 exception classes, no[/dim]", + " [dim]functions) passed only by calling log_operation at MODULE level --[/dim]", + " [dim]which fired during pytest COLLECTION, before any fixture, where no[/dim]", + " [dim]seam can intercept it. The check was rewarding the defect.[/dim]", + " [yellow]![/yellow] Narrow by construction: ONE method anywhere puts the module", + " [dim]back in scope. Constants do not buy it back -- data is still[/dim]", + " [dim]declarations. A file that does not PARSE is never exempt: a syntax[/dim]", + " [dim]error is not evidence of purity.[/dim]", + "", + "[bold cyan]EXEMPTION -- PRE-LOGGING BOOTSTRAP MODULES:[/bold cyan]", + " A module the LOGGING SUBSTRATE ITSELF imports, holding no aipass", + " import of any kind, is reported not_applicable. Importing json_handler", + " there is a cycle, and log_operation() WRITES -- so wiring it in puts a", + " file write on the import path of every branch in the fleet.", + " [dim]Why: @prax's handlers/repo_root.py is reached from inside the[/dim]", + " [dim]logger construction chain. Both ways to satisfy the rule[/dim]", + " [dim]reintroduce a defect the rule exists to prevent: an import-time[/dim]", + " [dim]write fires during pytest COLLECTION where no fixture can gate it,[/dim]", + " [dim]and a diagnostic that needs the logger it is emitted from is a[/dim]", + " [dim]second crash wearing a diagnostic's clothes (@memory).[/dim]", + " [yellow]![/yellow] BOTH clauses are measured by the checker, neither is declared:", + " [dim]no aipass import ANYWHERE (function-level counts), AND the logging[/dim]", + " [dim]chain's own imports reach the module. Measured on 2026-08-31: 79[/dim]", + " [dim]fleet modules import nothing from aipass; 9 are in the chain. One[/dim]", + " [dim]clause alone would exempt either all 79 or ai_mail's whole dispatch[/dim]", + " [dim]stack, which the chain reaches THROUGH json_handler and which logs.[/dim]", + " [dim]The walk is STATIC, so a dynamic import inside the chain leaves a[/dim]", + " [dim]module out and it stays in scope -- short, never long.[/dim]", + "", "[bold cyan]REFERENCE:[/bold cyan]", " [dim]See: seedgo standards pack (json_structure)[/dim]", " [dim]See: FPLAN-0056 (Three-JSON Pattern Redesign)[/dim]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/naming_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/naming_check.py index 6c1bf888d..5c8fb24e5 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/naming_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/naming_check.py @@ -228,6 +228,28 @@ def check_function_naming(content: str) -> Optional[Dict]: } +#: MODULE GLOBALS PYTEST READS BY NAME. Each of these is API, not style: +#: pytest looks them up at collection time by this exact lowercase spelling, so +#: an UPPER_CASE rename does not fail - it silently stops working while looking +#: tidier. @devpulse met this live, refused the auto-fix on +#: src/aipass/conftest.py, and reported it as a counterexample rather than +#: taking the rename; the rule learns it here because it is structurally +#: detectable, which is what a waiver would have been standing in for. +#: +#: Exempted BY NAME, not by file: this checker is handed content and never a +#: path, so a module outside a conftest that happens to use one of these +#: spellings is acquitted too. Stated rather than worked around - the names are +#: distinctive enough that the trade is worth one sentence. +PYTEST_CONTRACT_GLOBALS = frozenset( + { + "collect_ignore", + "collect_ignore_glob", + "pytest_plugins", + "pytestmark", + } +) + + def check_constant_naming(content: str) -> Optional[Dict]: """ Check constant naming conventions @@ -237,6 +259,9 @@ def check_constant_naming(content: str) -> Optional[Dict]: - Assigned outside of functions/classes (column 0 only) - Excludes module imports (from X import Y as Z) - Excludes __dunder__ variables (PEP 8 convention: always lowercase) + - Excludes the pytest contract globals (see PYTEST_CONTRACT_GLOBALS): those + spellings are read by pytest itself, so renaming them disables a + mechanism rather than tidying a name """ # First pass: collect imported names to exclude from constant checking imported_names = set() @@ -298,6 +323,10 @@ def check_constant_naming(content: str) -> Optional[Dict]: if const_name.startswith("__") and const_name.endswith("__"): continue + # Skip names pytest reads by their exact lowercase spelling. + if const_name in PYTEST_CONTRACT_GLOBALS: + continue + # Skip if this is an imported name (like logger, console) if const_name in imported_names: continue diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_check.py index 62f055e29..0ea45c5c2 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_check.py @@ -25,6 +25,7 @@ from typing import Dict from aipass.prax import logger +from aipass.seedgo.apps.handlers.aipass_standards import exception_handling from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed @@ -65,6 +66,208 @@ def _has_raise(nodes: list[ast.stmt]) -> bool: return False +#: Catching these says "something went wrong", which is not a classification. +#: DOTTED names are compared whole: ``pytest.skip.Exception`` is a specific type +#: whose last component happens to read "Exception", and matching on the +#: attribute alone rejected it (found by its own pin, 2026-08-31). +_BROAD_EXCEPTIONS = frozenset({"Exception", "BaseException", "builtins.Exception", "builtins.BaseException"}) + + +def _caught_type_names(handler: ast.ExceptHandler) -> list[str]: + """The exception names this handler catches; empty for a bare ``except:``.""" + caught = handler.type + if caught is None: + return [] + nodes = caught.elts if isinstance(caught, ast.Tuple) else [caught] + names = [] + for node in nodes: + dotted = _dotted_name(node) + if dotted: + names.append(dotted) + return names + + +def _dotted_name(node: ast.expr) -> str: + """``pytest.skip.Exception`` for an Attribute chain, ``ValueError`` for a Name.""" + parts = [] + current = node + while isinstance(current, ast.Attribute): + parts.append(current.attr) + current = current.value + if not isinstance(current, ast.Name): + return "" + parts.append(current.id) + return ".".join(reversed(parts)) + + +#: Builtins whose ZERO-ARGUMENT call is an empty container. ``set()`` and +#: ``frozenset()`` have no literal spelling at all, so a function returning one +#: has no way to say "nothing" that ``ast.Constant`` can see. +_EMPTY_BUILTINS = frozenset({"set", "frozenset", "dict", "list", "tuple"}) + + +def _is_the_empty_answer(value: ast.expr) -> bool: + """True for a value that means "this function found nothing". + + ``ast.Constant`` covers scalars only, so the clause below allowed + ``return ""`` and flagged ``return []`` — one idea, two spellings, split by + the type the function happens to return. Found by dogfooding: seedgo's own + cli_check helper scored 0 for ``except SyntaxError: return set()``. + + EMPTY only. ``return [1, 2]`` is fabricating an answer rather than + reporting an absence and keeps its finding, and so does ``set(c)`` — a + computed value wearing the empty constructor's name. + + Args: + value: The returned expression. + + Returns: + True for an empty literal container or a zero-argument call to one of + the empty-container builtins. + """ + if isinstance(value, (ast.List, ast.Tuple, ast.Set)): + return not value.elts + if isinstance(value, ast.Dict): + return not value.keys + if isinstance(value, ast.Call) and isinstance(value.func, ast.Name): + return value.func.id in _EMPTY_BUILTINS and not value.args and not value.keywords + return False + + +def _converts_the_exception_to_a_value(handler: ast.ExceptHandler) -> bool: + """True when the handler turns a NAMED exception into a returned constant. + + ``except FileNotFoundError: return "absent"`` does not swallow anything — + the exception's information becomes the return value and the caller decides + (@spawn, 2026-08-31, whose final_state() and _verdict() helpers answer + 'absent' / 'unreadable' / 'FAILED' / 'SKIPPED'). A logger call there would + route production logging out of a suite, which is what the hygiene lane + exists to stop, so the two standards were pulling opposite ways. + + TWO CLAUSES, both measured here: + 1. the handler names a SPECIFIC exception type — a bare ``except:`` or + ``except Exception`` catches everything, so "it failed" is all the + caller learns and the type carries no meaning; + 2. the body is exactly one ``return `` — control leaves with a + value. Anything else (a second statement, a computed return, a pass, a + continue) is not this shape and keeps its finding. "Constant" includes + the EMPTY ANSWER of any type (see _is_the_empty_answer): a function + contracted to return a list says "nothing found" as ``return []``, and + reading only ast.Constant made that verdict depend on the return type + rather than on what the handler does. + + Measured across the fleet before landing: 17 handlers match (14 in tests, + 3 in production), out of 27 single-return-constant handlers — the other 10 + catch Exception or bare and stay flagged. A narrow shape, not an amnesty. + """ + names = _caught_type_names(handler) + if not names or set(names) & _BROAD_EXCEPTIONS: + return False + if len(handler.body) != 1: + return False + only = handler.body[0] + if not isinstance(only, ast.Return) or only.value is None: + return False + if isinstance(only.value, ast.Constant) or _is_the_empty_answer(only.value): + return True + # `except AssertionError as exc: return ("FAILED", str(exc))` carries MORE + # than a constant does. Keying on ast.Constant alone flagged the better + # version of the same pattern (@spawn's _verdict, caught by running the + # rule against their real file rather than against my own examples). + if handler.name: + return any(isinstance(node, ast.Name) and node.id == handler.name for node in ast.walk(only.value)) + return False + + +def _reports_to_a_stream(handler: ast.ExceptHandler) -> bool: + """True when the handler writes the failure to stdout or stderr. + + THE STANDARD'S OWN WORD IS "SILENTLY". A handler that puts the exception on + the operator's screen is not silent by any reading of it, and this checker + was flagging one anyway because it recognised exactly one instrument. + + Found by @commons, 2026-08-31: their entry point repairs sys.path[0] before + any cross-branch import — @prax's logger is imported AFTER that block, and + importing it earlier is precisely what the repair exists to make safe. So + the cure this checker demanded could not be written, while the code already + did the thing the standard asks for. + + This is NOT a pre-logging-window exemption. It is a correction against the + standard's own definition, and it holds anywhere: a stream write reports. + Whether the stream is the RIGHT instrument is the CLI standard's question, + and it keeps asking it — a handler outside that bootstrap window that spells + its report as sys.stderr.write is still red over there, so this clause + cannot be used to escape Rich. Two checkers, two questions, neither + answering for the other. + + MEASURED before landing: exactly 1 handler in the fleet matches — the one + reported. A discriminator this narrow is worth stating out loud, because a + rule whose blast radius is the requester's own line is a waiver unless it is + re-derivable, and this one is: the next branch that hits the same window + gets the same answer without asking. + + Args: + handler: The except handler being judged. + + Returns: + True if the body writes to sys.stdout or sys.stderr. + """ + for node in ast.walk(handler): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + if node.func.attr != "write": + continue + stream = node.func.value + if isinstance(stream, ast.Attribute) and stream.attr in ("stdout", "stderr"): + return True + return False + + +def _judge_handler(handler: ast.ExceptHandler, try_node: ast.Try, module_path: str) -> list[int]: + """The line of ``handler`` if it swallows, or [] if it reports by some means. + + Every exemption is a separate named clause rather than one condition, so a + finding removed from this checker can be traced to the report that removed + it. + + Args: + handler: The except handler being judged. + try_node: The try statement it belongs to — two clauses read the block. + module_path: File being checked, for the exemption audit trail. + + Returns: + ``[handler.lineno]`` when the handler swallows, otherwise ``[]``. + """ + if not handler.body: + return [] + + # An except block is "silent" when it has neither a logger call nor a raise + # -- it swallows the exception without reporting it. + if _has_logger_call(handler.body) or _has_raise(handler.body): + return [] + + # Classifying, not swallowing: a named exception becomes a returned value. + if _converts_the_exception_to_a_value(handler): + return [] + + # Reporting, not swallowing: the failure reached a stream. "Silently" is this + # standard's own word, and a message on the operator's screen is not silence. + if _reports_to_a_stream(handler): + return [] + + # Guarding the diagnostic, not swallowing the error: the block this handler + # protects does nothing but report, so what is caught here is the failure OF + # the report (@daemon and @canary, 2026-08-31). + if exception_handling.guards_a_diagnostic(try_node, module_path, handler.lineno): + return [] + + # Handing it on, not dropping it: the exception leaves as an argument. + if exception_handling.hands_the_exception_on(handler, module_path): + return [] + + return [handler.lineno] + + def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: """ Check a Python file for silent exception catches. @@ -142,20 +345,14 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: # --- walk AST for silent ExceptHandler nodes -------------------------- silent_lines: list[int] = [] + # Walked as Try nodes, not bare handlers: two of the clauses below judge a + # handler by the BLOCK it protects, and an ExceptHandler node has no link + # back to its own try. for node in ast.walk(tree): - if not isinstance(node, ast.ExceptHandler): - continue - - body = node.body - if not body: + if not isinstance(node, ast.Try): continue - - # An except block is "silent" when it has neither a logger call - # nor a raise -- it swallows the exception without reporting it - if _has_logger_call(body) or _has_raise(body): - continue - - silent_lines.append(node.lineno) + for handler in node.handlers: + silent_lines.extend(_judge_handler(handler, node, module_path)) silent_lines.sort() diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_content.py index 7a6876a58..c85d7ece1 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/silent_catch_content.py @@ -95,6 +95,36 @@ def get_silent_catch_standards() -> str: " Via [dim].seedgo/bypass.json[/dim] -- supports standard and file-level", " bypass rules", "", + "[bold cyan]EXEMPTION -- CLASSIFYING, NOT SWALLOWING:[/bold cyan]", + " An except handler that turns a NAMED exception into a returned value", + " is not a silent catch: the exception's information IS the return, and", + " the caller decides. Reported as passing.", + " [dim]except FileNotFoundError: return 'absent' -- allowed[/dim]", + " [dim]except AssertionError as e: return ('FAILED', str(e)) -- allowed[/dim]", + " [yellow]![/yellow] TWO clauses, both measured, and it is deliberately narrow:", + " [dim]1. a SPECIFIC exception type -- bare except and except Exception[/dim]", + " [dim]say only that something failed, which classifies nothing;[/dim]", + " [dim]2. a body of EXACTLY one return, whose value is a constant or[/dim]", + " [dim]mentions the bound exception. Extra statements, a pass, a[/dim]", + " [dim]continue, or a computed return that drops the exception all keep[/dim]", + " [dim]their finding. Measured fleet-wide before landing: 19 handlers[/dim]", + " [dim]qualify (16 test, 3 production) -- a shape, not an amnesty.[/dim]", + ' [yellow]![/yellow] "Constant" includes the EMPTY ANSWER of any return type:', + " [green]return [] return {} return () return set()[/green] allowed", + " [red]return [1, 2] return set(c) return build()[/red] flagged", + ' [dim]A function contracted to return a list says "found nothing" as[/dim]', + " [dim]`return []`, and set() has no literal spelling at all -- reading[/dim]", + " [dim]only ast.Constant made the verdict depend on the return TYPE. An[/dim]", + " [dim]empty container is an absence; a filled one is a fabricated[/dim]", + " [dim]answer, and set(c) is a computed value wearing the same name.[/dim]", + " [dim]Found by dogfooding seedgo's own checker (2026-08-31).[/dim]", + " [yellow]![/yellow] A COUNTER is not this shape: `except X: missing += 1` may be", + " [dim]asserted on later or read by nobody, and the checker cannot see[/dim]", + " [dim]which -- so the finding stands and the reader rules on it.[/dim]", + " [dim]Raised by @spawn (2026-08-31): a logger call in a test helper[/dim]", + " [dim]would route production logging out of a suite, which is what the[/dim]", + " [dim]hygiene lane exists to stop -- two standards pulling opposite ways.[/dim]", + "", "[bold cyan]REFERENCE:[/bold cyan]", " [dim]See: seedgo standards pack (silent_catch)[/dim]", " [dim]Checker: silent_catch_check.py[/dim]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function.md b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function.md index 0081cb28c..7fdf46d0c 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function.md +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function.md @@ -135,3 +135,19 @@ Example bypass rule: - **Scope:** `branch_level` - **Entry point:** `check_branch(branch_path, bypass_rules)` - **Standard label:** `UNUSED_FUNCTION` + +## What this check actually measures + +**"No caller in this branch"** — not "unused". It is a static, per-branch AST scan, so +**cross-branch and dynamic (`importlib`) callers are invisible to it in principle.** +Confirm before deleting. + +**Why the wording matters.** On 2026-08-31 this check nominated +`memory/apps/handlers/json/entry_limits.changed_entries()` — called by `@hooks` at +`security/edit_gate.py:496` through a dynamic import. It is the entry-cap half of the +fleet's write gate; deleting it would have disabled that gate for every branch. + +The danger is that this check is **useful**. `@memory` had correctly acted on it earlier +the same night and dropped a genuinely dead helper. An agent that trusts the word +"unused" will eventually delete a real cross-branch entry point to get a green audit — +and the audit will go green. The measurement was sound; only the word overclaimed. diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py index 68b946b94..f93d47062 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py @@ -323,12 +323,23 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: details.append(f" ... and {len(unused_functions) - 15} more") detail_text = "\n".join(details) + # SAYS WHAT IT MEASURED, and no more. This scan is per-branch and + # static; it cannot see a cross-branch consumer, least of all one that + # arrives through importlib. It said "unused" and nominated + # entry_limits.changed_entries() — called by @hooks at + # security/edit_gate.py:496, the entry-cap half of the fleet's write + # gate (@memory, 2026-08-31). The measurement was right; the word was + # not, and the danger is that this check is USEFUL enough to be trusted + # into deleting a real entry point for a green score. checks = [ { "name": "Unused functions", "passed": passed, "message": ( - f"{len(unused_functions)} unused out of {total_functions} functions ({score}% clean)\n{detail_text}" + f"{len(unused_functions)} with no caller in this branch, out of " + f"{total_functions} functions ({score}% clean) — a static per-branch scan; " + f"cross-branch and dynamic (importlib) callers are NOT visible to it, so " + f"confirm before deleting\n{detail_text}" ), "unused": unused_functions, } diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_content.py index 8ae82c64c..769978cd4 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_content.py @@ -98,6 +98,17 @@ def get_unused_function_standards() -> str: " Via [dim].seedgo/bypass.json[/dim] -- supports standard, file,", " and file+line-level bypass rules", "", + "[bold cyan]WHAT THIS CHECK MEASURES:[/bold cyan]", + " 'No caller in THIS BRANCH' -- not 'unused'. A static per-branch AST", + " scan cannot see cross-branch or dynamic (importlib) callers.", + " [yellow]![/yellow] CONFIRM BEFORE DELETING.", + " [dim]On 2026-08-31 this check nominated entry_limits.changed_entries()[/dim]", + " [dim]-- called by @hooks at security/edit_gate.py:496 through a dynamic[/dim]", + " [dim]import. It is the entry-cap half of the fleet's write gate.[/dim]", + " [dim]The danger is that this check is USEFUL: an agent that trusts the[/dim]", + " [dim]word 'unused' will eventually delete a real cross-branch entry[/dim]", + " [dim]point for a green audit -- and the audit will go green.[/dim]", + "", "[bold cyan]REFERENCE:[/bold cyan]", " [dim]See: seedgo standards pack (unused_function)[/dim]", " [dim]Checker: unused_function_check.py[/dim]", diff --git a/src/aipass/seedgo/apps/handlers/audit/artifact.py b/src/aipass/seedgo/apps/handlers/audit/artifact.py index f3f3fa962..aae8438af 100644 --- a/src/aipass/seedgo/apps/handlers/audit/artifact.py +++ b/src/aipass/seedgo/apps/handlers/audit/artifact.py @@ -55,12 +55,13 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers.module_root import module_file # Bump when the doc SHAPE changes so consumers can gate on it. SCHEMA_VERSION = 1 # audit/ -> handlers/ -> apps/ -> seedgo/ (never a hardcoded path) -_SEEDGO_ROOT = Path(__file__).resolve().parents[3] +_SEEDGO_ROOT = module_file(__file__).parents[3] ARTIFACT_DIR_NAME = ".seedgo" ARTIFACT_FILE_NAME = "last_audit.json" diff --git a/src/aipass/seedgo/apps/handlers/audit/audit_display.py b/src/aipass/seedgo/apps/handlers/audit/audit_display.py index 7c22c0685..ff54d27d4 100644 --- a/src/aipass/seedgo/apps/handlers/audit/audit_display.py +++ b/src/aipass/seedgo/apps/handlers/audit/audit_display.py @@ -230,12 +230,23 @@ def print_branch_summary( avg = audit_result["average"] files_checked = audit_result.get("files_checked", 0) - # Branch header - always show files checked + # Branch header - always says WHAT was measured, never just how much. + # + # It read "N files checked" until 2026-08-31. The audit's corpus is + # apps/**/*.py and nothing else: tests/ is never walked, so on a branch + # that is a third test code by file count, a 100 stood for a number the + # reader had no way to compute. @devpulse's ruling the same morning, on + # this branch's own report of it: the honest sentence ships now; aligning + # the walk to each checker's APPLIES_TO (+136 findings across 12 branches) + # is a separate, scheduled fleet change needing Patrick's GO. A score that + # stops overclaiming is a fix, not a change. cached_tag = " [dim](cached)[/dim]" if audit_result.get("_cache_hit") else "" no_bypass_tag = " [bold yellow][BYPASSES DISABLED][/bold yellow]" if no_bypass else "" console.print() console.print( - f"[bold cyan]{branch['name']}[/bold cyan] [dim]({files_checked} files checked)[/dim]{cached_tag}{no_bypass_tag}" + f"[bold cyan]{branch['name']}[/bold cyan] " + f"[dim]({files_checked} production files measured — apps/ only, tests/ not in the corpus)[/dim]" + f"{cached_tag}{no_bypass_tag}" ) # Scores in a grid diff --git a/src/aipass/seedgo/apps/handlers/audit/discovery.py b/src/aipass/seedgo/apps/handlers/audit/discovery.py index 67d75c9f2..9e35ee3fe 100644 --- a/src/aipass/seedgo/apps/handlers/audit/discovery.py +++ b/src/aipass/seedgo/apps/handlers/audit/discovery.py @@ -26,6 +26,7 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers import registry_scan # ============================================================================= # PRIVATE BRANCH DETECTION @@ -60,34 +61,15 @@ def _find_registry() -> Path: CWD-first matches drone's registry_handler search order and supports external projects with their own registries. """ - # Walk up from CWD first — this is where the user is working - cwd = Path.cwd() - for parent in [cwd] + list(cwd.parents): - matches = sorted(parent.glob("*_REGISTRY.json")) - if matches: - return matches[0] - # Fallback: walk up from this file (pip editable installs) - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - matches = sorted(parent.glob("*_REGISTRY.json")) - if matches: - return matches[0] - return Path.cwd() / "AIPASS_REGISTRY.json" + # Delegates: the name is the contract, the private glob walk was the disease. + # parent.glob("*_REGISTRY.json") folds case on Windows and default macOS, so + # a flow_json plan counter could be served as the trust anchor. + return registry_scan.find_registry() def _find_caller_registries() -> List[Path]: """Find registries from the caller's project via AIPASS_CALLER_CWD.""" - import os - - caller_cwd = os.environ.get("AIPASS_CALLER_CWD", "") - if not caller_cwd: - return [] - caller_path = Path(caller_cwd) - for parent in [caller_path] + list(caller_path.parents): - matches = sorted(parent.glob("*_REGISTRY.json")) - if matches: - return matches - return [] + return registry_scan.caller_registries() def _branches_from_registry(registry_path: Path) -> List[Dict[str, str]]: diff --git a/src/aipass/seedgo/apps/handlers/audit/incremental_cache.py b/src/aipass/seedgo/apps/handlers/audit/incremental_cache.py index a577a081c..bb5c58786 100644 --- a/src/aipass/seedgo/apps/handlers/audit/incremental_cache.py +++ b/src/aipass/seedgo/apps/handlers/audit/incremental_cache.py @@ -46,6 +46,7 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers.module_root import module_file # Bump when the fingerprint/stamp ALGORITHM changes (belt-and-braces bust). CACHE_VERSION = "1" @@ -63,8 +64,8 @@ # 17/17 cached green while CI, which has no cache, showed the real 99%. A stale # green is worse than a slow audit. MACHINERY_DIRS: Tuple[Path, ...] = ( - Path(__file__).resolve().parent, - Path(__file__).resolve().parent.parent / "bypass", + module_file(__file__).parent, + module_file(__file__).parent.parent / "bypass", ) diff --git a/src/aipass/seedgo/apps/handlers/audit_tests/adapters.py b/src/aipass/seedgo/apps/handlers/audit_tests/adapters.py index 7e8d21b2b..f392b2098 100644 --- a/src/aipass/seedgo/apps/handlers/audit_tests/adapters.py +++ b/src/aipass/seedgo/apps/handlers/audit_tests/adapters.py @@ -41,6 +41,7 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers.module_root import module_file #: The adapter API version this core speaks. SUPPORTED_ADAPTER_API = 1 @@ -68,7 +69,7 @@ PAYLOAD_DIR = "payload" #: Where adapter packs live. -HANDLERS_ROOT = Path(__file__).resolve().parents[1] +HANDLERS_ROOT = module_file(__file__).parents[1] #: Pack directory suffix. Kept per Patrick's ruling 2026-08-29 19:23 — #: "don't change the command path, it already works". diff --git a/src/aipass/seedgo/apps/handlers/audit_tests/artifact.py b/src/aipass/seedgo/apps/handlers/audit_tests/artifact.py index 81083f59d..8fbd477ca 100644 --- a/src/aipass/seedgo/apps/handlers/audit_tests/artifact.py +++ b/src/aipass/seedgo/apps/handlers/audit_tests/artifact.py @@ -37,12 +37,13 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.audit_tests import laws, refusal, spine, target as target_module from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers.module_root import module_file ARTIFACT_VERSION = "audit-tests/1" LANE_VERSION = "1.0.0" #: Where artifacts live. Seedgo's own state directory, never the target's. -SEEDGO_ROOT = Path(__file__).resolve().parents[3] +SEEDGO_ROOT = module_file(__file__).parents[3] ARTIFACT_DIR = SEEDGO_ROOT / ".seedgo" #: Baseline marker for a pair the lane has never measured before. diff --git a/src/aipass/seedgo/apps/handlers/audit_tests/runner.py b/src/aipass/seedgo/apps/handlers/audit_tests/runner.py index 97666ce19..f2484fcee 100644 --- a/src/aipass/seedgo/apps/handlers/audit_tests/runner.py +++ b/src/aipass/seedgo/apps/handlers/audit_tests/runner.py @@ -447,13 +447,24 @@ def _probe_live_writers(target: target_module.Target, options: dict) -> None: """ if options.get("no_m10_proof"): return + options["live_writer_probe_seconds"] = None + started = time.perf_counter() try: options["live_writers"] = m10.live_writers(target.path) options["live_writers_probed"] = True + # PUBLISHED, not just measured. A bare `probed: true` reads as "probed + # ADEQUATELY", and this window is milliseconds against a run of tens of + # seconds - so a service writing once a minute is missed and its paths + # surface as unattributed. @trigger hit exactly that (a systemd user + # unit, six files) and only found it by identifying the paths by hand. + # The reader cannot make the comparison unless BOTH numbers are here. + options["live_writer_probe_seconds"] = round(time.perf_counter() - started, 4) except OSError as exc: # Recorded, never assumed empty: an unmeasured live-writer set makes # every concurrent write read as an M10 violation, which is the safe - # direction but must not look like a clean probe. + # direction but must not look like a clean probe. The window stays + # None rather than becoming the elapsed-until-failure, which would + # read as a window that measured something. logger.warning(f"[AUDIT-TESTS] live-writer probe of {target.path} failed: {exc}") options["live_writers_probed"] = False @@ -514,6 +525,7 @@ def _m10_proof( "unattributed_changes": sorted(changed - recorded - concurrent), "attributed_to_concurrent_writers": sorted((changed & concurrent) - recorded), "live_writers_probed": bool(options.get("live_writers_probed")), + "live_writer_probe_seconds": options.get("live_writer_probe_seconds"), "gate_recorded_paths": len(recorded), "files_fingerprinted": len(before), "diff": diff, @@ -569,6 +581,7 @@ def _m10_not_probed(reason: str) -> dict: "unattributed_changes": [], "attributed_to_concurrent_writers": [], "live_writers_probed": False, + "live_writer_probe_seconds": None, "gate_recorded_paths": 0, "files_fingerprinted": 0, "diff": {}, diff --git a/src/aipass/seedgo/apps/handlers/audit_tests/selfcheck.py b/src/aipass/seedgo/apps/handlers/audit_tests/selfcheck.py index 10894721d..5ffc51543 100644 --- a/src/aipass/seedgo/apps/handlers/audit_tests/selfcheck.py +++ b/src/aipass/seedgo/apps/handlers/audit_tests/selfcheck.py @@ -149,17 +149,18 @@ def _bytecode_row(config_note: str) -> dict: def _m10_rows(m10_proof: dict) -> List[dict]: """Checks 1 and 12 - the real tree, fingerprinted at both ends.""" probed = bool(m10_proof.get("probed")) - intact = m10_proof.get("real_tree_unchanged") if not probed: detail = str(m10_proof.get("note", "the fingerprint could not be taken")) return [ _check(1, "pristine fingerprint of the real tree, taken at START", "RESTORE-AT-START", FAIL, detail), - _check(12, "the real target is byte-unchanged after the run", "OBSERVER-FORGERY", FAIL, detail), + _check(12, "the measured suite wrote nothing into the real target", "OBSERVER-FORGERY", FAIL, detail), + _check(17, "every real-tree change was ATTRIBUTED", "UNATTRIBUTED-CHANGE", FAIL, detail), ] count = m10_proof.get("files_fingerprinted", 0) unattributed = m10_proof.get("unattributed_changes", []) + by_the_suite = m10_proof.get("changed_by_the_measured_suite", []) concurrent = m10_proof.get("attributed_to_concurrent_writers", []) # Check 12 fails on what NOTHING ELSE was seen writing. A live citizen's @@ -173,7 +174,9 @@ def _m10_rows(m10_proof: dict) -> List[dict]: detail = ( f"{count} file(s) re-hashed after teardown; {len(unattributed)} change(s) nothing else " f"was observed writing, {len(concurrent)} attributed to the target's own concurrent " - f"writers (probe ran: {m10_proof.get('live_writers_probed')}); diff: " + f"writers (probe ran: {m10_proof.get('live_writers_probed')}, window " + f"{m10_proof.get('live_writer_probe_seconds')}s - a window shorter than the run " + f"UNDER-detects, so an unattributed change may still be a live service); diff: " f"{m10_proof.get('diff', {})}" ) return [ @@ -184,12 +187,34 @@ def _m10_rows(m10_proof: dict) -> List[dict]: PASS, f"{count} file(s) hashed before the copy was made", ), + # SPLIT FROM THE UNATTRIBUTED SET, on @trigger's report 2026-08-30. + # This row is the finding the lane exists to make: the gate watched the + # suite in-process and NAMED the real-tree paths it opened for writing. + # Keying the red on that, and only that, is what stops a recipient + # reading "your suite dirtied the real tree" off a branch whose suite + # wrote nothing and whose ten moved files were a systemd unit and the + # citizen answering their own mail mid-audit. _check( 12, - "no change to the real target that the run cannot account for", + "the measured suite wrote nothing into the real target", "OBSERVER-FORGERY", + PASS if not by_the_suite else FAIL, + ( + f"the gate observed the suite writing {len(by_the_suite)} real-tree path(s): {by_the_suite}" + if by_the_suite + else f"{count} file(s) re-hashed after teardown; the gate recorded the suite writing none of them" + ), + ), + # The honest remainder, never folded into the row above and never + # exempted away. A path allowance for `.ai_mail.local/` or a systemd + # name match would both be DECLARED discriminators, which is precisely + # what `live_writers` refuses to be - so this stays red and says why. + _check( + 17, + "every real-tree change was ATTRIBUTED", + "UNATTRIBUTED-CHANGE", PASS if not unattributed else FAIL, - detail if not intact else f"{count} file(s) re-hashed after teardown; byte-identical", + detail, ), ] diff --git a/src/aipass/seedgo/apps/handlers/bypass/bypass_handler.py b/src/aipass/seedgo/apps/handlers/bypass/bypass_handler.py index e9f3b4cee..baa7b125b 100644 --- a/src/aipass/seedgo/apps/handlers/bypass/bypass_handler.py +++ b/src/aipass/seedgo/apps/handlers/bypass/bypass_handler.py @@ -22,6 +22,7 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers import registry_scan # ============================================================================= @@ -57,17 +58,10 @@ def _find_registry() -> Path: """Find *_REGISTRY.json — CWD-first for external project support, then __file__ fallback.""" - cwd = Path.cwd() - for parent in [cwd] + list(cwd.parents): - matches = sorted(parent.glob("*_REGISTRY.json")) - if matches: - return matches[0] - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - matches = sorted(parent.glob("*_REGISTRY.json")) - if matches: - return matches[0] - return Path.cwd() / "AIPASS_REGISTRY.json" + # Delegates: the name is the contract, the private glob walk was the disease. + # parent.glob("*_REGISTRY.json") folds case on Windows and default macOS, so + # a flow_json plan counter could be served as the trust anchor. + return registry_scan.find_registry() # ============================================================================= diff --git a/src/aipass/seedgo/apps/handlers/bypass/inert.py b/src/aipass/seedgo/apps/handlers/bypass/inert.py index 2e0ed966f..c1c77353d 100644 --- a/src/aipass/seedgo/apps/handlers/bypass/inert.py +++ b/src/aipass/seedgo/apps/handlers/bypass/inert.py @@ -31,9 +31,10 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.aipass_standards import applicability from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers.module_root import module_file # Every checker in the pack lives under this package's parent. -HANDLERS_ROOT = Path(__file__).resolve().parents[1] +HANDLERS_ROOT = module_file(__file__).parents[1] PACK_ROOT = HANDLERS_ROOT / "aipass_standards" diff --git a/src/aipass/seedgo/apps/handlers/diagnostics/diagnostics_check.py b/src/aipass/seedgo/apps/handlers/diagnostics/diagnostics_check.py index c5f3198e3..2970b683a 100644 --- a/src/aipass/seedgo/apps/handlers/diagnostics/diagnostics_check.py +++ b/src/aipass/seedgo/apps/handlers/diagnostics/diagnostics_check.py @@ -28,13 +28,14 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.bypass.ignore_handler import get_audit_ignore_patterns from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers.module_root import module_file console = Console() AUDIT_SCOPE = "branch_level" # Diagnostics handler directory (where this file lives) -DIAGNOSTICS_DIR = Path(__file__).resolve().parent +DIAGNOSTICS_DIR = module_file(__file__).parent # Handlers root (parent of diagnostics/) HANDLERS_DIR = DIAGNOSTICS_DIR.parent diff --git a/src/aipass/seedgo/apps/handlers/diagnostics/discovery.py b/src/aipass/seedgo/apps/handlers/diagnostics/discovery.py index 796738279..9584d61c1 100644 --- a/src/aipass/seedgo/apps/handlers/diagnostics/discovery.py +++ b/src/aipass/seedgo/apps/handlers/diagnostics/discovery.py @@ -18,21 +18,15 @@ from aipass.prax.apps.modules.logger import system_logger as logger from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers import registry_scan def _find_registry() -> Path: """Find *_REGISTRY.json — CWD-first for external project support, then __file__ fallback.""" - cwd = Path.cwd() - for parent in [cwd] + list(cwd.parents): - matches = sorted(parent.glob("*_REGISTRY.json")) - if matches: - return matches[0] - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - matches = sorted(parent.glob("*_REGISTRY.json")) - if matches: - return matches[0] - return Path.cwd() / "AIPASS_REGISTRY.json" + # Delegates: the name is the contract, the private glob walk was the disease. + # parent.glob("*_REGISTRY.json") folds case on Windows and default macOS, so + # a flow_json plan counter could be served as the trust anchor. + return registry_scan.find_registry() def discover_branches() -> List[Dict]: diff --git a/src/aipass/seedgo/apps/handlers/json/json_handler.py b/src/aipass/seedgo/apps/handlers/json/json_handler.py index 19f21e535..ced23e49d 100755 --- a/src/aipass/seedgo/apps/handlers/json/json_handler.py +++ b/src/aipass/seedgo/apps/handlers/json/json_handler.py @@ -22,8 +22,9 @@ from typing import Dict, Any, Optional from aipass.prax import logger +from aipass.seedgo.apps.handlers.module_root import module_file -_BRANCH_ROOT = Path(__file__).resolve().parents[3] # json/ -> handlers/ -> apps/ -> {branch}/ +_BRANCH_ROOT = module_file(__file__).parents[3] # json/ -> handlers/ -> apps/ -> {branch}/ _BRANCH_NAME = _BRANCH_ROOT.name JSON_DIR = _BRANCH_ROOT / f"{_BRANCH_NAME}_json" @@ -181,8 +182,17 @@ def get_json_path(module_name: str, json_type: str) -> Path: return JSON_DIR / filename -def ensure_json_exists(module_name: str, json_type: str) -> bool: - """Ensure JSON file exists, create from template if missing""" +def ensure_json_exists(module_name: str, json_type: str) -> None: + """Ensure JSON file exists, create from template if missing. + + Returns nothing on purpose. This was annotated `-> bool` and returned True + on every path, which advertises a failure signal that never arrives and + invites `if not ensure_json_exists(...)` — a branch that can never be taken. + Failure is reported by exception: _atomic_write_json raises OSError. + + Raises: + OSError: The template could not be written. + """ JSON_DIR.mkdir(parents=True, exist_ok=True) json_path = get_json_path(module_name, json_type) @@ -193,7 +203,7 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: data = json.load(f) if validate_json_structure(data, json_type): - return True + return # If corrupted, fall through to regenerate except Exception: logger.info("JSON file unreadable or corrupted, regenerating: %s", json_path) @@ -201,7 +211,6 @@ def ensure_json_exists(module_name: str, json_type: str) -> bool: template = _create_default(json_type, module_name) _atomic_write_json(json_path, template) - return True def load_json(module_name: str, json_type: str) -> Optional[Any]: @@ -213,8 +222,11 @@ def load_json(module_name: str, json_type: str) -> Optional[Any]: template so callers always get a valid structure. A non-empty but malformed file still raises (fail honestly — that is real corruption, not a race). """ - if not ensure_json_exists(module_name, json_type): - return None + # No `if not ensure_json_exists(...)` guard: it returns nothing and reports + # failure by raising. The guard that used to be here could never fire — the + # function returned an unconditional True — and it is exactly the dead + # branch a bool-that-is-always-True invites a caller to write. + ensure_json_exists(module_name, json_type) json_path = get_json_path(module_name, json_type) @@ -242,12 +254,19 @@ def save_json(module_name: str, json_type: str, data: Any) -> bool: return True -def ensure_module_jsons(module_name: str) -> bool: - """Ensure all 3 JSON files exist for a module""" +def ensure_module_jsons(module_name: str) -> None: + """Ensure all 3 JSON files exist for a module. + + Returns nothing on purpose — see ensure_json_exists. This previously + discarded three booleans and then returned an unconditional True, so it + reported success no matter what the three calls did. + + Raises: + OSError: Any of the three templates could not be written. + """ ensure_json_exists(module_name, "config") ensure_json_exists(module_name, "data") ensure_json_exists(module_name, "log") - return True def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool: diff --git a/src/aipass/seedgo/apps/handlers/module_root.py b/src/aipass/seedgo/apps/handlers/module_root.py new file mode 100644 index 000000000..565f08825 --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/module_root.py @@ -0,0 +1,92 @@ +# =================== AIPass ==================== +# Name: module_root.py +# Description: Resolve a module's __file__ without an import-time cwd read +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""One guarded spelling for module-level ``__file__`` resolution. + +THE MECHANISM. ``ntpath.realpath`` calls ``os.getcwd()`` UNCONDITIONALLY — on +its first lines, before it checks whether the path is even relative, which is +where it differs from ``posixpath`` — and ``Path.resolve()`` routes straight +through it. So on Windows every module-level ``Path(__file__).resolve()`` is an +import-time working-directory read, and a process whose cwd was deleted cannot +import the module at all. Measured on the Windows CI gate 2026-08-31 +(@memory's finding, relayed by @devpulse). + +WHAT IT COST HERE. Twelve seedgo modules resolved ``__file__`` at import to +build a constant, and the guard in ``handlers/__init__.py`` did it a thirteenth +time before any of them. The audit is the one instrument that must survive a +broken world in order to describe it: a standards auditor that cannot be +imported without a readable cwd cannot report that anything else needs one. + +THE DISCRIMINATOR IS *REACHED AT IMPORT*, not *written at module scope* — a +default argument counts if import-time code calls it, and a module-scope line +inside a function body does not. Which is why the twelve sites were found by +RUNNING the world (``Path.resolve`` wrapped to record its caller while every +seedgo module was imported), never by grepping: the tree holds 75 ``.resolve()`` +call sites and 61 of them are call-time. + +Shape mirrors @memory's ``repo_root.module_file`` — the ratified fleet cure — +sized to this branch: seedgo has no module-level repo-root walk, so only the +resolve guard lives here. +""" + +from pathlib import Path + +from aipass.prax import logger + +MODULE_NAME = "module_root" + + +def module_file(file: str) -> Path: + """A module's own path, absolute, resolved when the filesystem allows. + + ``.resolve()`` is still ATTEMPTED, because normalising symlinks is why the + call is there and it succeeds on every healthy machine. The fallback is only + reached in the world where the alternative is a dead import, and it is + sound: ``__file__`` has been absolute since Python 3.9, so the return names + the right file either way — just spelled through the symlink rather than + past it. + + Args: + file: A module's ``__file__``. + + Returns: + The module's path, resolved if the filesystem could be asked. + """ + path = Path(file) + try: + return path.resolve() + except OSError as exc: + # info, not warning: in the world that reaches this line EVERY module + # import takes it, and twelve identical warnings describe one condition. + # Whichever lane actually fails on it is the one that should be loud. + logger.info("%s: cannot resolve %s (%s) — using its absolute spelling", MODULE_NAME, path, type(exc).__name__) + _record_unresolved(path, exc) + return path + + +def _record_unresolved(path: Path, exc: OSError) -> None: + """Record the fallback without ever raising. + + Runs at module import time on all twelve callers; a diagnostic write that + fails in a bare world must not become the import crash this module exists to + prevent. The bare ``Exception`` is the point, not an oversight. + + Args: + path: The ``__file__`` that could not be resolved. + exc: The OSError ``resolve()`` raised. + """ + try: + from aipass.seedgo.apps.handlers.json import json_handler + + json_handler.log_operation( + "module_file_unresolved", + {"path": str(path), "error": f"{type(exc).__name__}: {exc}"}, + module_name=MODULE_NAME, + ) + except Exception as inner: # noqa: BLE001 - an audit line must never take an import down + logger.info("%s: fallback not recorded: %s: %s", MODULE_NAME, type(inner).__name__, inner) diff --git a/src/aipass/seedgo/apps/handlers/readme/readme_ops.py b/src/aipass/seedgo/apps/handlers/readme/readme_ops.py index eb88ab378..3b06ff674 100644 --- a/src/aipass/seedgo/apps/handlers/readme/readme_ops.py +++ b/src/aipass/seedgo/apps/handlers/readme/readme_ops.py @@ -21,6 +21,8 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers import registry_scan +from aipass.seedgo.apps.handlers.module_root import module_file # ============================================================================= # INFRASTRUCTURE SETUP @@ -32,21 +34,14 @@ def _find_registry() -> Path: """Find *_REGISTRY.json — CWD-first for external project support, then __file__ fallback.""" - cwd = Path.cwd() - for parent in [cwd] + list(cwd.parents): - matches = sorted(parent.glob("*_REGISTRY.json")) - if matches: - return matches[0] - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - matches = sorted(parent.glob("*_REGISTRY.json")) - if matches: - return matches[0] - return Path.cwd() / "AIPASS_REGISTRY.json" + # Delegates: the name is the contract, the private glob walk was the disease. + # parent.glob("*_REGISTRY.json") folds case on Windows and default macOS, so + # a flow_json plan counter could be served as the trust anchor. + return registry_scan.find_registry() # Generator lives in the same handlers/readme/ directory as this file -GENERATOR_PATH = Path(__file__).resolve().parent / "readme_generator.py" +GENERATOR_PATH = module_file(__file__).parent / "readme_generator.py" # Section display names for output SECTION_NAMES = { diff --git a/src/aipass/seedgo/apps/handlers/registry_scan.py b/src/aipass/seedgo/apps/handlers/registry_scan.py new file mode 100644 index 000000000..91ff80aab --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/registry_scan.py @@ -0,0 +1,137 @@ +# =================== AIPass ==================== +# Name: registry_scan.py +# Description: Case-Exact Registry Discovery +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +""" +Case-Exact Registry Discovery + +One reader for "which files in this directory are registries", used by every +lane in seedgo that needs to find one. + +WHY THIS EXISTS. Nine sites in this branch called +``parent.glob("*_REGISTRY.json")``. pathlib matches that pattern +CASE-INSENSITIVELY on Windows and on default macOS, so ``*_registry.json`` +matched too, and every branch is full of bait: ten ``flow_json/*_registry.json`` +plan counters and ``.spawn/.template_registry.json`` — which pathlib's ``*`` +matches despite the leading dot, unlike the glob module. Measured on CI by +@drone: ``find_registry`` returned ``drone_command_registry.json`` as the +trust-anchor candidate (@devpulse, 2026-08-31, found on ef029782's +windows-setup leg). + +WHAT IT COST HERE. An audit that discovers branches through a plan counter +audits the wrong world, and a bypass keyed off the wrong registry grants an +exemption where nobody declared one. + +THE FIX. List the directory and re-check the NAME with a case-sensitive +``str.endswith``. The check is on the SUFFIX only, never the stem: external +projects name their registry after themselves and nothing promises an uppercase +stem — ``Vera-Studio_REGISTRY.json`` is a real one and must survive. + +The cure already existed in the fleet (``prax/branch_detector.py`` and eight +aipass sites list-and-check) and never traveled to the sites that globbed. One +reader is what makes a tenth walk hard to write; a parse-tree pin in +``tests/test_registry_case_sweep.py`` makes it fail out loud. +""" + +import os +from pathlib import Path +from typing import List, Optional + +from aipass.prax import logger +from aipass.seedgo.apps.handlers.json import json_handler + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +#: The suffix a registry file's name must end with, EXACTLY. Cased on purpose: +#: this string is the whole defence, and comparing it case-insensitively puts +#: the defect straight back. +REGISTRY_SUFFIX = "_REGISTRY.json" + +#: What a lane names when no registry exists anywhere above it. Kept identical +#: to the four private copies this module replaced, so the case fix changes the +#: case behaviour and nothing else. +DEFAULT_REGISTRY_NAME = "AIPASS_REGISTRY.json" + +CALLER_CWD_VAR = "AIPASS_CALLER_CWD" + + +# ============================================================================= +# PUBLIC API +# ============================================================================= + + +def registries_in(directory: Path) -> List[Path]: + """Registry files sitting directly in ``directory``, name-exact and sorted. + + Args: + directory: Directory to list. Unreadable or missing directories answer + [] rather than raising — a walk up the tree crosses both. + + Returns: + Sorted list of paths whose NAME ends with ``_REGISTRY.json``, case + exactly. Directories are excluded; a folder can carry any name. + """ + try: + entries = sorted(directory.iterdir()) + except OSError as exc: + logger.info("registry_scan: cannot list %s: %s", directory, exc) + return [] + return [entry for entry in entries if entry.name.endswith(REGISTRY_SUFFIX) and entry.is_file()] + + +def find_registry_upward(start: Path) -> Optional[Path]: + """First registry at or above ``start``, or None if the walk finds none.""" + for parent in [start] + list(start.parents): + found = registries_in(parent) + if found: + return found[0] + return None + + +def find_registry() -> Path: + """The registry this process should read: CWD first, then this file's tree. + + CWD-first matches drone's registry_handler search order and is what lets an + external project with its own registry be audited from inside itself. The + __file__ walk is the pip-editable-install fallback. + + Returns: + Path to the registry found, or ``/AIPASS_REGISTRY.json`` when the + whole walk comes up empty. That last value is a NAME, not a file that + exists; callers already treat a non-existent registry as an empty world. + """ + from_cwd = find_registry_upward(Path.cwd()) + if from_cwd is not None: + return from_cwd + from_source = find_registry_upward(Path(__file__).resolve().parent) + if from_source is not None: + return from_source + return Path.cwd() / DEFAULT_REGISTRY_NAME + + +def caller_registries() -> List[Path]: + """Every registry in the first directory at or above the CALLING project. + + The caller's own working directory arrives in ``AIPASS_CALLER_CWD`` from + drone, because seedgo's own cwd is seedgo. Unset means no caller context, + which is not an error — it is how a direct run looks. + """ + caller_cwd = os.environ.get(CALLER_CWD_VAR, "") + if not caller_cwd: + return [] + caller_path = Path(caller_cwd) + for parent in [caller_path] + list(caller_path.parents): + found = registries_in(parent) + if found: + json_handler.log_operation( + "caller_registries_found", + {"directory": str(parent), "count": len(found)}, + ) + return found + return [] diff --git a/src/aipass/seedgo/apps/handlers/tests_pytest_standards/adapter.py b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/adapter.py index 0e871d37b..0a078d5de 100644 --- a/src/aipass/seedgo/apps/handlers/tests_pytest_standards/adapter.py +++ b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/adapter.py @@ -41,6 +41,7 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.tests_pytest_standards import envcopy, gatelog, nominators +from aipass.seedgo.apps.handlers.module_root import module_file ADAPTER_API = 1 ECOSYSTEM = "pytest" @@ -58,9 +59,11 @@ "static_assertion_shape", "static_capture_never_read", "static_coverage_slot", + "static_empty_parametrize", "static_entry_point_diff", "static_mock_drift", "static_no_oracle", + "static_posix_literal", "static_ruff_pt", "static_self_skip", "static_unentered_assert", @@ -105,7 +108,7 @@ ) #: The injected plugin, by path. Copied into the env, never imported here. -PAYLOAD_DIR = Path(__file__).resolve().parent / "payload" +PAYLOAD_DIR = module_file(__file__).parent / "payload" PLUGIN_FILE = PAYLOAD_DIR / "audit_hygiene_plugin.py" #: Directory names that mean "this project has pytest units". diff --git a/src/aipass/seedgo/apps/handlers/tests_pytest_standards/empty_parametrize.md b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/empty_parametrize.md new file mode 100644 index 000000000..5af8b788f --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/empty_parametrize.md @@ -0,0 +1,55 @@ +# Empty Parametrize (static nominator) +**Status:** Active v1 +**Tier:** STATIC — nominates, never convicts (Law M1) +**Species:** VANISHING-TABLE, SHORT-TABLE +**Rule:** TAXONOMY section 5 rule 3a - a table computed at collection time + +--- + +## What it flags + +- parametrize argvalues drawn from a function call, whose empty return pytest reports as SKIPPED while the suite summary reads green +- the same table where the file's only guard asserts NON-EMPTINESS - a collector that drops one entry still satisfies it (SHORT-TABLE) + +## What it must never flag + +- a literal list/tuple/set with elements - it cannot be empty +- a module-level name bound to a non-empty literal +- a safe builtin over a literal: range(24), sorted(LITERAL) +- a file whose guard pins an expected COUNT, not merely non-emptiness + +## What it cannot see + +- a table legitimately empty on some machines is nominated; that is why this tier nominates and the execution tier convicts (Law M1) +- the guard clause matches an assertion anywhere in the FILE, not one proven to cover this particular table - it errs toward acquitting + +## The fix + +assert the collection is non-empty in a test of its own, and derive that assertion from the raw data rather than from the function being judged - a probe that calls the collector cannot detect a blinded collector. + +## Measured + +@drone's test_bypass_anchors.py survived a collector-blinding mutant and reported '1 passed, 2 skipped' (2026-08-31, reported unprompted with the cure) + +--- + +## Why this group is never scored + +Law M1 splits the tiers: static **nominates**, execution **convicts**. A nomination +says a test is suspect; it never says a test is worthless, and Law S7b closes the +verdict vocabulary against the delete family for exactly that reason. + +Law M11 is the reason the rows carry a `deletion_safety` field that currently says +`probed: false`. TAXONOMY corpus row 26 is the worked example: @daemon's +`HANDLED_COMMANDS` membership tests read as tautologies and are the only pins on the +name of a verb that, renamed, falls through and turns the fleet's scheduler off — with +all 481 tests green. A checker that flagged those pins and got them deleted would have +made the branch worse. + +## Why the static tier can never be retired + +Design section 4.2a-bis, CONTRACT 0. Mutation's unit of judgement is the **mutant**, +not the **test**, so any healthy test on a symbol masks every weak one beside it. +Measured: a MIRROR-EXPECT test survived a constant mutant while its spelled-out twin +killed the same mutant — so nothing was reported at all. A per-mutant verdict cannot +structurally name a per-test defect, whatever the execution tier grows into. diff --git a/src/aipass/seedgo/apps/handlers/tests_pytest_standards/empty_parametrize_check.py b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/empty_parametrize_check.py new file mode 100644 index 000000000..9036538fd --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/empty_parametrize_check.py @@ -0,0 +1,279 @@ +# =================== AIPass ==================== +# Name: empty_parametrize_check.py +# Description: nominator - a parametrize table computed at collection time (VANISHING-TABLE) +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +""" +A parametrized test over an EMPTY collection reports as passing. + + @pytest.mark.parametrize("item", collect()) + def test_every_found_item_is_valid(item): + assert item["ok"] + +If ``collect()`` returns ``[]``, pytest generates no cases, marks the test +SKIPPED, and the run prints ``1 passed, 1 skipped`` with exit code 0. +Reproduced verbatim before this rule was written. The instrument checked +nothing and reported the same green a clean tree reports. + +WHY THIS IS ITS OWN RULE AND NOT PART OF `self_skip`. That rule asks where a +skip's CONDITION gets its answer, and finds skips written in the source. Here +there is no skip in the source at all — pytest manufactures it from an empty +argvalues sequence. A reader grepping for ``skip`` finds nothing, which is +what makes this the quieter species of the two. + +WHERE IT CAME FROM. @drone hit it building the content-anchored bypass rule +(2026-08-31): their first version of ``test_bypass_anchors.py`` SURVIVED a +mutant that blinded the collector to ``return []``, because the anchor checks +were parametrized over the collector's output and the whole file came back +"1 passed, 2 skipped". Their arming probe asserted the raw bypass list was +non-empty, which is a different question from whether the collector found +anything. They reported it unprompted with the cure: recount the entries +INDEPENDENTLY, walking the raw data rather than calling the function under +judgement. + +THE ACQUITTALS MATTER MORE THAN THE FLAGS HERE. A literal table cannot be +empty, and neither can a module constant bound to a non-empty literal, and a +file that already carries an independent non-empty guard has done the thing +this rule exists to ask for. Measured across the fleet before landing: 312 +parametrize sites, 217 of them plain literals that this rule never looks at. + +NOMINATION, NEVER CONVICTION (Law M1). A table that is legitimately empty on +some machines — a platform sweep with no rows on this OS — is the honest case, +and the execution tier rules on it. +""" + +import ast +from typing import List + +from aipass.seedgo.apps.handlers.tests_pytest_standards import corpus + +#: The adapter group this nominator fills. Namespaced by the core. +GROUP = "static_empty_parametrize" + +#: Builtins that cannot invent emptiness on their own: given a non-empty +#: literal they return something non-empty. ``range`` is here because +#: ``range(24)`` is a table written in shorthand, not a query. +SAFE_BUILTINS: frozenset = frozenset({"range", "sorted", "list", "tuple", "reversed", "enumerate", "set"}) + +SPECIFICATION = { + "rule": "TAXONOMY section 5 rule 3a - a table computed at collection time", + "species": ["VANISHING-TABLE", "SHORT-TABLE"], + "flags": [ + "parametrize argvalues drawn from a function call, whose empty return " + "pytest reports as SKIPPED while the suite summary reads green", + "the same table where the file's only guard asserts NON-EMPTINESS - a " + "collector that drops one entry still satisfies it (SHORT-TABLE)", + ], + "exempts": [ + "a literal list/tuple/set with elements - it cannot be empty", + "a module-level name bound to a non-empty literal", + "a safe builtin over a literal: range(24), sorted(LITERAL)", + "a file whose guard pins an expected COUNT, not merely non-emptiness", + ], + "fix": ( + "assert the collection is non-empty in a test of its own, and derive that " + "assertion from the raw data rather than from the function being judged - " + "a probe that calls the collector cannot detect a blinded collector." + ), + "limits": [ + "a table legitimately empty on some machines is nominated; that is why this " + "tier nominates and the execution tier convicts (Law M1)", + "the guard clause matches an assertion anywhere in the FILE, not one proven " + "to cover this particular table - it errs toward acquitting", + ], + "evidence": ( + "@drone's test_bypass_anchors.py survived a collector-blinding mutant and " + "reported '1 passed, 2 skipped' (2026-08-31, reported unprompted with the cure)" + ), +} + + +def _module_literal_names(parsed: corpus.TestFile) -> set: + """Module-level names bound to a non-empty literal container. + + Args: + parsed: The test module. + + Returns: + Names that cannot be empty at collection time. + """ + safe = set() + for node in parsed.tree.body: + targets = [] + if isinstance(node, ast.Assign): + targets = node.targets + elif isinstance(node, ast.AnnAssign): + targets = [node.target] + value = getattr(node, "value", None) + if isinstance(value, (ast.List, ast.Tuple, ast.Set)) and value.elts: + safe.update(t.id for t in targets if isinstance(t, ast.Name)) + elif isinstance(value, ast.Dict) and value.keys: + safe.update(t.id for t in targets if isinstance(t, ast.Name)) + return safe + + +def _cannot_be_empty(value: ast.expr, safe_names: set) -> bool: + """True when this argvalues expression is non-empty by construction. + + Unwraps one layer of a safe builtin, so ``sorted(WORLDS)`` is judged on + ``WORLDS``. One layer, deliberately: following an arbitrary chain would + make this an interpreter, and Law M10 forbids running the subject. + + Args: + value: The second positional argument to ``parametrize``. + safe_names: Module names bound to non-empty literals. + + Returns: + True if the table cannot vanish. + """ + if isinstance(value, (ast.List, ast.Tuple, ast.Set)): + return bool(value.elts) + if isinstance(value, ast.Dict): + return bool(value.keys) + if isinstance(value, ast.Constant): + return bool(value.value) + if isinstance(value, ast.Name): + return value.id in safe_names + if isinstance(value, ast.Call): + name = corpus.dotted_name(value.func).rsplit(".", 1)[-1] + if name not in SAFE_BUILTINS or not value.args: + return False + return _cannot_be_empty(value.args[0], safe_names) + return False + + +def _guard_pins_a_count(parsed: corpus.TestFile) -> bool: + """True when some assertion compares a length against a derived EXPECTED value. + + @trigger's round-9 find, and the species is a notch smaller than the one + this file was built for: a collector that silently drops ONE entry leaves a + non-empty table, every surviving case still passes, and the run is two cases + lighter than it should be. An empty run at least looks odd; a short one + looks like a normal run. + + A guard asserting ``len(x)`` is truthy answers "did it find anything". A + guard asserting ``len(x) == expected`` answers "did it find them all", and + only the second notices a short table. + + Measured before this split shipped: of 10 files the guard clause acquits + fleet-wide, 7 already pin a count and 3 assert only non-emptiness - so the + new species nominates three sites, not a tree. + + Args: + parsed: The test module. + + Returns: + True if a count-pinning assertion exists anywhere in the file. + """ + for node in ast.walk(parsed.tree): + if not isinstance(node, ast.Assert): + continue + for sub in ast.walk(node.test): + if not isinstance(sub, ast.Compare): + continue + if not (isinstance(sub.left, ast.Call) and corpus.dotted_name(sub.left.func).rsplit(".", 1)[-1] == "len"): + continue + for op, comparator in zip(sub.ops, sub.comparators): + if not isinstance(op, ast.Eq): + continue + # `len(x) == 0` is an emptiness assertion, not a count. + if isinstance(comparator, ast.Constant) and comparator.value == 0: + continue + return True + return False + + +def _has_independent_nonempty_guard(parsed: corpus.TestFile) -> bool: + """True when some test in this file asserts a collection is non-empty. + + The cure @drone built, detected in the shape it is usually written: an + assertion comparing a length against zero, or asserting a collection + truthy. Matched anywhere in the file rather than proven to cover the table + in question — this rule errs toward acquitting, because a false flag on a + file that already did the work teaches nothing. + + Args: + parsed: The test module. + + Returns: + True if an arming probe of that shape exists. + """ + for node in ast.walk(parsed.tree): + if not isinstance(node, ast.Assert): + continue + for sub in ast.walk(node.test): + if isinstance(sub, ast.Call) and corpus.dotted_name(sub.func).rsplit(".", 1)[-1] == "len": + return True + return False + + +def _parametrize_tables(unit: corpus.TestUnit) -> List[tuple]: + """Every ``parametrize`` decorator on a unit as ``(argvalues, lineno)``. + + Args: + unit: The test function or class. + + Returns: + One entry per parametrize decorator carrying argvalues. + """ + tables = [] + for decorator in unit.decorators: + if not isinstance(decorator, ast.Call): + continue + if corpus.dotted_name(decorator.func).rsplit(".", 1)[-1] != "parametrize": + continue + if len(decorator.args) < 2: + continue + tables.append((decorator.args[1], decorator.lineno)) + return tables + + +def nominate(scanned: corpus.Corpus) -> List[dict]: + """Every parametrize table that could vanish without the suite noticing. + + Args: + scanned: The parsed corpus. + + Returns: + Nomination rows, one per unguarded computed table. + """ + rows: List[dict] = [] + + for parsed in scanned.files: + guarded = _has_independent_nonempty_guard(parsed) + counted = _guard_pins_a_count(parsed) + if guarded and counted: + continue + safe_names = _module_literal_names(parsed) + for unit in parsed.units: + for argvalues, lineno in _parametrize_tables(unit): + if _cannot_be_empty(argvalues, safe_names): + continue + if guarded: + rows.append( + corpus.nomination( + "SHORT-TABLE", + unit, + f"parametrize table computed at collection time ({ast.unparse(argvalues)[:60]}) " + "- the file's guard asserts the collection is NON-EMPTY, which a collector that " + "drops one entry still satisfies; pin the expected COUNT from raw data", + line=lineno, + evidence={"argvalues": ast.unparse(argvalues)[:120], "guard": "non-empty only"}, + ) + ) + continue + rows.append( + corpus.nomination( + "VANISHING-TABLE", + unit, + f"parametrize table computed at collection time ({ast.unparse(argvalues)[:60]}) " + "- an empty result is reported as SKIPPED and the suite summary reads green", + line=lineno, + evidence={"argvalues": ast.unparse(argvalues)[:120]}, + ) + ) + + return rows diff --git a/src/aipass/seedgo/apps/handlers/tests_pytest_standards/empty_parametrize_content.py b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/empty_parametrize_content.py new file mode 100644 index 000000000..7b13496be --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/empty_parametrize_content.py @@ -0,0 +1,25 @@ +# =================== AIPass ==================== +# Name: empty_parametrize_content.py +# Description: queryable content for the empty_parametrize nominator +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Queryable content for the `empty_parametrize` static nominator. + +DERIVED, NEVER RESTATED. The text comes from `empty_parametrize_check.py`'s own +`SPECIFICATION`, so the documentation and the detector cannot disagree. +""" + +from aipass.seedgo.apps.handlers.tests_pytest_standards import render_spec +from aipass.seedgo.apps.handlers.tests_pytest_standards.empty_parametrize_check import SPECIFICATION + + +def get_empty_parametrize_standards() -> str: + """Return the empty_parametrize nominator's specification, Rich-rendered. + + Returns: + str: Formatted standards text derived from the checker's SPECIFICATION + """ + return render_spec.render("empty_parametrize", SPECIFICATION) diff --git a/src/aipass/seedgo/apps/handlers/tests_pytest_standards/nominators.py b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/nominators.py index b968abcf9..c310ba138 100644 --- a/src/aipass/seedgo/apps/handlers/tests_pytest_standards/nominators.py +++ b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/nominators.py @@ -47,9 +47,10 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.tests_pytest_standards import corpus +from aipass.seedgo.apps.handlers.module_root import module_file #: Where nominators live, and the filename shape that declares one. -PACK_DIR = Path(__file__).resolve().parent +PACK_DIR = module_file(__file__).parent NOMINATOR_GLOB = "*_check.py" #: What a nominator must expose. diff --git a/src/aipass/seedgo/apps/handlers/tests_pytest_standards/posix_literal.md b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/posix_literal.md new file mode 100644 index 000000000..2417200b9 --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/posix_literal.md @@ -0,0 +1,55 @@ +# Posix Literal (static nominator) +**Status:** Active v1 +**Tier:** STATIC — nominates, never convicts (Law M1) +**Species:** POSIX-LITERAL +**Rule:** TAXONOMY section 5 rule 3b - a rooted path literal put through a resolver + +--- + +## What it flags + +- a path constructor over a rooted string literal with .resolve() called on it +- os.path.realpath or os.path.abspath over a rooted string literal + +## What it must never flag + +- any other object's resolve() - a branch-name resolver shares the verb and nothing else (6 of 10 sites in the loose arm were exactly that) +- a literal that is not rooted - a relative fragment carries no platform claim +- a path built from tmp_path, os.sep or a fixture rather than written down + +## What it cannot see + +- reads the RECEIVER, so a path handed through a variable is not seen - this rule errs short rather than nominating every resolve in the fleet +- a test deliberately exercising POSIX spelling is nominated; that is why this tier nominates and the execution tier convicts (Law M1) +- walks TEST UNITS, so a literal resolved in a fixture or at module level is not seen - the same bias toward FEWER nominations the rest of this tier has + +## The fix + +derive the path from tmp_path or os.sep, or state the platform claim out loud - parametrise both dialects, or assert on Path.parts rather than on a spelling. Where the literal IS the subject, keep it and say so: a rooted literal is drive-relative on Windows, not invalid. + +## Measured + +@drone's windows-setup red, round 7: a pin comparing against 'RESOLVED: /tmp' got D:\tmp from ntpath and accused a working wrapper (2026-08-31, reported with the species named and the acquittal rate asked for before the rule) + +--- + +## Why this group is never scored + +Law M1 splits the tiers: static **nominates**, execution **convicts**. A nomination +says a test is suspect; it never says a test is worthless, and Law S7b closes the +verdict vocabulary against the delete family for exactly that reason. + +Law M11 is the reason the rows carry a `deletion_safety` field that currently says +`probed: false`. TAXONOMY corpus row 26 is the worked example: @daemon's +`HANDLED_COMMANDS` membership tests read as tautologies and are the only pins on the +name of a verb that, renamed, falls through and turns the fleet's scheduler off — with +all 481 tests green. A checker that flagged those pins and got them deleted would have +made the branch worse. + +## Why the static tier can never be retired + +Design section 4.2a-bis, CONTRACT 0. Mutation's unit of judgement is the **mutant**, +not the **test**, so any healthy test on a symbol masks every weak one beside it. +Measured: a MIRROR-EXPECT test survived a constant mutant while its spelled-out twin +killed the same mutant — so nothing was reported at all. A per-mutant verdict cannot +structurally name a per-test defect, whatever the execution tier grows into. diff --git a/src/aipass/seedgo/apps/handlers/tests_pytest_standards/posix_literal_check.py b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/posix_literal_check.py new file mode 100644 index 000000000..1e6cbb4bc --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/posix_literal_check.py @@ -0,0 +1,210 @@ +# =================== AIPass ==================== +# Name: posix_literal_check.py +# Description: nominator - a rooted path literal put through a resolver (POSIX-LITERAL) +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +r""" +A rooted string literal resolved by a test is asserting posixpath semantics. + + slash_tmp = Path("/tmp").resolve() + assert slash_tmp in roots + +On POSIX that is ``/tmp``. On Windows ``/tmp`` is DRIVE-RELATIVE — ntpath +attaches the current drive and ``resolve()`` returns ``D:\tmp`` — so the same +line means a different thing on the other half of the matrix, and the assertion +underneath it accuses code that is working perfectly. + +WHERE IT CAME FROM. @drone's one windows-setup red in round 7 was a return-value +pin they had added THAT MORNING to catch a platform assumption: it compared +against ``RESOLVED: /tmp`` and CI handed it ``D:\tmp``. They reported it with +the species named and asked for the acquittal rate before it became a rule, +which is the right order. @devpulse arrived at the same construct from the other +side the same evening. + +THE MEASUREMENT THAT DECIDED THE SHAPE, run before this file existed, over 721 +test files and 32,841 assert statements: + + - "an assert containing a rooted string literal" ....... 501 sites, 112 files + - "a rooted literal reaching any callable named + resolve / realpath / abspath" ...................... 10 sites, 3 files + - THIS RULE (the receiver must BE a path constructor, + or the callee an os.path function) .................. 4 sites, 1 file + +The middle arm is the instructive one. Six of its ten sites were +``target_module.resolve("@canary", {...})`` — a BRANCH-NAME resolver that +happens to share a verb with pathlib, holding a rooted literal in a dict value +it never resolves. A rule keyed on the method NAME nominates those six forever, +and a nominator with that acquittal rate teaches the fleet to ignore it inside a +week. Keyed on the RECEIVER instead, it nominates none of them. + +NOMINATION, NEVER CONVICTION (Law M1). A test that deliberately exercises POSIX +spelling — a fence refusing ``/etc/passwd``, a parser fed a known-rooted +input — is a legitimate site, and the execution tier rules on it. What this rule +buys is that the decision gets MADE rather than inherited from whichever +platform the author happened to be standing on. +""" + +import ast +from typing import List, Optional + +from aipass.seedgo.apps.handlers.tests_pytest_standards import corpus + +#: The adapter group this nominator fills. Namespaced by the core. +GROUP = "static_posix_literal" + +#: Constructors whose first argument is a path. A ``.resolve()`` hanging off one +#: of these is pathlib's resolve and no other object's. +PATH_CONSTRUCTORS: frozenset = frozenset( + {"Path", "PurePath", "PurePosixPath", "PureWindowsPath", "PosixPath", "WindowsPath"} +) + +#: Module-level functions that normalise a path against the process state. +RESOLVER_FUNCTIONS: frozenset = frozenset({"realpath", "abspath"}) + +SPECIFICATION = { + "rule": "TAXONOMY section 5 rule 3b - a rooted path literal put through a resolver", + "species": ["POSIX-LITERAL"], + "flags": [ + "a path constructor over a rooted string literal with .resolve() called on it", + "os.path.realpath or os.path.abspath over a rooted string literal", + ], + "exempts": [ + "any other object's resolve() - a branch-name resolver shares the verb " + "and nothing else (6 of 10 sites in the loose arm were exactly that)", + "a literal that is not rooted - a relative fragment carries no platform claim", + "a path built from tmp_path, os.sep or a fixture rather than written down", + ], + "fix": ( + "derive the path from tmp_path or os.sep, or state the platform claim out " + "loud - parametrise both dialects, or assert on Path.parts rather than on a " + "spelling. Where the literal IS the subject, keep it and say so: a rooted " + "literal is drive-relative on Windows, not invalid." + ), + "limits": [ + "reads the RECEIVER, so a path handed through a variable is not seen - this " + "rule errs short rather than nominating every resolve in the fleet", + "a test deliberately exercising POSIX spelling is nominated; that is why this " + "tier nominates and the execution tier convicts (Law M1)", + "walks TEST UNITS, so a literal resolved in a fixture or at module level is " + "not seen - the same bias toward FEWER nominations the rest of this tier has", + ], + "evidence": ( + "@drone's windows-setup red, round 7: a pin comparing against 'RESOLVED: /tmp' " + "got D:\\tmp from ntpath and accused a working wrapper (2026-08-31, reported " + "with the species named and the acquittal rate asked for before the rule)" + ), +} + + +def _is_rooted_literal(node: ast.expr) -> bool: + """True when this expression is a string literal starting at a root. + + Args: + node: Any expression node. + + Returns: + True for ``"/tmp"`` and ``"C:\\\\tmp"``, False for ``"tmp"``. + """ + if not isinstance(node, ast.Constant) or not isinstance(node.value, str): + return False + text = node.value + if not text: + return False + if text[0] in ("/", "\\"): + return True + return len(text) > 2 and text[1] == ":" and text[2] in ("/", "\\") and text[0].isalpha() + + +def _resolved_path_constructor(call: ast.Call) -> Optional[ast.expr]: + """The rooted literal a ``.resolve()`` receiver was built from, if any. + + Keyed on the RECEIVER rather than the method name: ``Path("/tmp").resolve()`` + is pathlib, ``registry.resolve("@canary", ...)`` is a branch-name lookup that + happens to share a verb. Measured before choosing - the name test nominates + six such sites fleet-wide and this one nominates none of them. + + Args: + call: A call node whose func is an Attribute. + + Returns: + The literal node, or None. + """ + if not isinstance(call.func, ast.Attribute): + return None + receiver = call.func.value + if not isinstance(receiver, ast.Call) or not isinstance(receiver.func, ast.Name): + return None + if receiver.func.id not in PATH_CONSTRUCTORS or not receiver.args: + return None + return receiver.args[0] if _is_rooted_literal(receiver.args[0]) else None + + +def _resolver_function(call: ast.Call) -> Optional[ast.expr]: + """The rooted literal an ``os.path.realpath``-shaped call was handed. + + Args: + call: A call node whose func is an Attribute. + + Returns: + The literal node, or None. + """ + if not isinstance(call.func, ast.Attribute): + return None + if call.func.attr not in RESOLVER_FUNCTIONS or not call.args: + return None + if not corpus.dotted_name(call.func.value).endswith("path"): + return None + return call.args[0] if _is_rooted_literal(call.args[0]) else None + + +def _resolved_literals(unit: corpus.TestUnit) -> List[tuple]: + """Every rooted literal this unit puts through a resolver. + + Args: + unit: The test function or class. + + Returns: + ``(literal_text, lineno)`` pairs. + """ + found = [] + for node in ast.walk(unit.node): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + if node.func.attr == "resolve": + literal = _resolved_path_constructor(node) + else: + literal = _resolver_function(node) + if isinstance(literal, ast.Constant): + found.append((literal.value, node.lineno)) + return found + + +def nominate(scanned: corpus.Corpus) -> List[dict]: + """Every rooted path literal a test puts through a resolver. + + Args: + scanned: The parsed corpus. + + Returns: + Nomination rows, one per resolved literal. + """ + rows: List[dict] = [] + + for unit in scanned.units(): + for text, lineno in _resolved_literals(unit): + rows.append( + corpus.nomination( + "POSIX-LITERAL", + unit, + f"rooted path literal {text!r} put through a resolver - ntpath makes " + "a rooted literal DRIVE-RELATIVE, so this line means something else " + "on the other half of the matrix", + line=lineno, + evidence={"literal": text}, + ) + ) + + return rows diff --git a/src/aipass/seedgo/apps/handlers/tests_pytest_standards/posix_literal_content.py b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/posix_literal_content.py new file mode 100644 index 000000000..458365fb6 --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/tests_pytest_standards/posix_literal_content.py @@ -0,0 +1,25 @@ +# =================== AIPass ==================== +# Name: posix_literal_content.py +# Description: queryable content for the posix_literal nominator +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Queryable content for the `posix_literal` static nominator. + +DERIVED, NEVER RESTATED. The text comes from `posix_literal_check.py`'s own +`SPECIFICATION`, so the documentation and the detector cannot disagree. +""" + +from aipass.seedgo.apps.handlers.tests_pytest_standards import render_spec +from aipass.seedgo.apps.handlers.tests_pytest_standards.posix_literal_check import SPECIFICATION + + +def get_posix_literal_standards() -> str: + """Return the posix_literal nominator's specification, Rich-rendered. + + Returns: + str: Formatted standards text derived from the checker's SPECIFICATION + """ + return render_spec.render("posix_literal", SPECIFICATION) diff --git a/src/aipass/seedgo/tests/test_audit_tests_lane.py b/src/aipass/seedgo/tests/test_audit_tests_lane.py index eef1109b0..e42ef0c88 100644 --- a/src/aipass/seedgo/tests/test_audit_tests_lane.py +++ b/src/aipass/seedgo/tests/test_audit_tests_lane.py @@ -1485,3 +1485,219 @@ def test_a_help_flag_beside_an_unknown_token_still_explains(self, monkeypatch): verb.handle_command("audit-tests", ["-tests", "--help"]) assert seen == ["help"] + + +class TestLiveWriterProbeWindowIsDisclosed: + """`live_writers_probed: true` must never read as "probed ADEQUATELY". + + THE DEFECT THIS PINS, reported by @trigger 2026-08-30 with this lane's own + artifact as the evidence. Their artifact said `live_writers_probed: true` + and `attributed_to_concurrent_writers: []`, and ten files had changed + anyway - six of them written by a systemd USER unit (trigger-log-watcher, + up since 08-27) that writes continuously. + + The probe is two BACK-TO-BACK snapshots, so its window is milliseconds + while the run it is meant to explain takes tens of seconds. `live_writers` + already confesses this in its own docstring ("a service that writes once a + minute will not show up"), and the confession is correct - but it lives in + the source, and the artifact published a bare `true`. A reader comparing a + 0.02s probe against a 40s run cannot make that comparison unless BOTH + numbers are in the document. + + So the fix is disclosure, not a path exemption and not a systemd name + match: @trigger proposed reading `systemctl --user list-units`, and that is + a DECLARED discriminator of exactly the kind `live_writers` refuses to be - + it would let anything that names itself a service explain away its writes. + The window is published instead, and the reader judges. + """ + + def _proof(self, **options): + from aipass.seedgo.apps.handlers.audit_tests import runner + + return runner._m10_not_probed("test"), options + + def test_the_not_probed_proof_carries_the_window_field_too(self): + """A missing field and a zero window must not look the same. + + The not-probed shape is built to mirror the probed one exactly - the + module says so in its own words - so a field added to one and not the + other reintroduces the drift that shape was created to prevent. + """ + from aipass.seedgo.apps.handlers.audit_tests import runner + + assert "live_writer_probe_seconds" in runner._m10_not_probed("test") + + def test_an_unprobed_run_publishes_none_not_a_misleading_zero(self): + from aipass.seedgo.apps.handlers.audit_tests import runner + + assert runner._m10_not_probed("test")["live_writer_probe_seconds"] is None + + def test_the_probe_records_how_long_its_window_actually_was(self, tmp_path): + from aipass.seedgo.apps.handlers.audit_tests import runner + from aipass.seedgo.apps.handlers.audit_tests import target as target_module + + (tmp_path / "f.txt").write_text("x") + options: dict = {} + runner._probe_live_writers( + target_module.Target(name="t", path=tmp_path, kind="branch", resolved_from="test"), options + ) + + assert options["live_writers_probed"] is True + window = options["live_writer_probe_seconds"] + assert isinstance(window, float) + assert window >= 0.0 + + def test_a_failed_probe_still_publishes_a_window_of_none(self, tmp_path, monkeypatch): + """The probe failing is the case where a bare `true` misleads most.""" + from aipass.seedgo.apps.handlers.audit_tests import m10, runner + from aipass.seedgo.apps.handlers.audit_tests import target as target_module + + def boom(*a, **k): + raise OSError("no") + + monkeypatch.setattr(m10, "live_writers", boom) + options: dict = {} + runner._probe_live_writers( + target_module.Target(name="t", path=tmp_path, kind="branch", resolved_from="test"), options + ) + + assert options["live_writers_probed"] is False + assert options["live_writer_probe_seconds"] is None + + def test_the_unattributed_row_states_the_window_beside_the_probe_flag(self): + """The two numbers must meet in ONE sentence to be comparable. + + Repointed to check 17 when the verdict was split (@trigger, same day): + the window is what makes an UNATTRIBUTED red judgeable, so it belongs on + the row that reds on unattributed changes, not on the forgery row. + """ + from aipass.seedgo.apps.handlers.audit_tests import selfcheck + + proof = { + "probed": True, + "files_fingerprinted": 3, + "unattributed_changes": ["a"], + "attributed_to_concurrent_writers": [], + "live_writers_probed": True, + "live_writer_probe_seconds": 0.02, + "changed_by_the_measured_suite": [], + "diff": {}, + } + checks = selfcheck._m10_rows(proof) + row = [c for c in checks if c["check"] == 17] + assert row, "check 17 must be present" + assert "0.02" in row[0]["detail"] + + def test_the_PUBLISHED_proof_carries_the_window_not_just_the_options_dict(self, tmp_path): + """The reader of the ARTIFACT is who needs this number. + + The first cut of this class pinned the options dict and the not-probed + shape and left the probed artifact block unpinned - deleting the line + that copies the window into the published proof kept all five tests + green. A disclosure nobody can read is not a disclosure, so the pin + belongs on the document, not on the intermediate state. + """ + from aipass.seedgo.apps.handlers.audit_tests import m10, runner + from aipass.seedgo.apps.handlers.audit_tests import target as target_module + + (tmp_path / "f.txt").write_text("x") + target = target_module.Target(name="t", path=tmp_path, kind="branch", resolved_from="test") + before = m10.snapshot_tree(tmp_path) + options: dict = {} + runner._probe_live_writers(target, options) + + proof = runner._m10_proof(target, before, options) + + assert proof["probed"] is True + assert "live_writer_probe_seconds" in proof + assert proof["live_writer_probe_seconds"] == options["live_writer_probe_seconds"] + assert isinstance(proof["live_writer_probe_seconds"], float) + + +class TestCheck12VerdictIsSplitFromUnattributedChanges: + """A FAIL must name what it found, not what it could not explain. + + THE MISREAD, reported by @trigger 2026-08-30. Check 12 is named "no change + to the real target that the run cannot account for" and it FAILED on their + branch - honestly, because ten files really did move. But it keyed the + verdict on `unattributed_changes` alone, while the artifact three fields up + said `changed_by_the_measured_suite: []`. To a recipient the red reads as + "your suite dirtied the real tree", and for @trigger it was six files + written by a systemd user unit that has run since 08-27 and four written by + the citizen answering their own mail WHILE the audit ran. + + Both facts deserve a row. The suite-attributed finding is the one this lane + exists to make and it keeps check 12 and the OBSERVER-FORGERY name. The + unattributed set is a genuine "a human must look at this" and gets its own + row that says so. Nothing is downgraded, nothing is exempted - a path + allowance for `.ai_mail.local/` or a systemd name-match would both be + DECLARED discriminators, and `live_writers` refuses to be one. + """ + + def _proof(self, *, by_suite, unattributed): + return { + "probed": True, + "files_fingerprinted": 9, + "unattributed_changes": unattributed, + "attributed_to_concurrent_writers": [], + "live_writers_probed": True, + "live_writer_probe_seconds": 0.02, + "changed_by_the_measured_suite": by_suite, + "real_tree_unchanged": not (by_suite or unattributed), + "diff": {}, + } + + def _row(self, rows, number): + found = [r for r in rows if r["check"] == number] + assert found, f"check {number} must be present" + return found[0] + + def test_check_12_passes_when_the_SUITE_changed_nothing(self): + """@trigger's exact case: ten unattributed, zero from the suite.""" + from aipass.seedgo.apps.handlers.audit_tests import selfcheck + + rows = selfcheck._m10_rows(self._proof(by_suite=[], unattributed=["a", "b"])) + assert self._row(rows, 12)["status"] == selfcheck.PASS + + def test_check_12_still_fails_when_the_suite_DID_write_the_real_tree(self): + """The finding the lane exists for must not be softened by the split.""" + from aipass.seedgo.apps.handlers.audit_tests import selfcheck + + rows = selfcheck._m10_rows(self._proof(by_suite=["logs/x.jsonl"], unattributed=[])) + assert self._row(rows, 12)["status"] == selfcheck.FAIL + assert "logs/x.jsonl" in self._row(rows, 12)["detail"] + + def test_the_unattributed_set_gets_its_own_row_and_is_not_swallowed(self): + """Splitting must not become hiding.""" + from aipass.seedgo.apps.handlers.audit_tests import selfcheck + + rows = selfcheck._m10_rows(self._proof(by_suite=[], unattributed=["a", "b"])) + assert self._row(rows, 17)["status"] == selfcheck.FAIL + + def test_the_unattributed_row_names_the_probe_window_so_the_red_is_judgeable(self): + """A short probe UNDER-detects; the reader needs that number here.""" + from aipass.seedgo.apps.handlers.audit_tests import selfcheck + + rows = selfcheck._m10_rows(self._proof(by_suite=[], unattributed=["a"])) + assert "0.02" in self._row(rows, 17)["detail"] + + def test_the_two_rows_do_not_share_a_name(self): + """If both rows say the same thing the split bought nothing.""" + from aipass.seedgo.apps.handlers.audit_tests import selfcheck + + rows = selfcheck._m10_rows(self._proof(by_suite=["x"], unattributed=["a"])) + assert self._row(rows, 12)["name"] != self._row(rows, 17)["name"] + + def test_a_clean_run_passes_both_rows(self): + from aipass.seedgo.apps.handlers.audit_tests import selfcheck + + rows = selfcheck._m10_rows(self._proof(by_suite=[], unattributed=[])) + assert self._row(rows, 12)["status"] == selfcheck.PASS + assert self._row(rows, 17)["status"] == selfcheck.PASS + + def test_an_unprobed_proof_publishes_both_rows_rather_than_one(self): + """A missing row and a passing row must never look the same.""" + from aipass.seedgo.apps.handlers.audit_tests import selfcheck + + rows = selfcheck._m10_rows({"probed": False, "note": "no fingerprint"}) + assert {r["check"] for r in rows} >= {1, 12, 17} diff --git a/src/aipass/seedgo/tests/test_audit_tests_static.py b/src/aipass/seedgo/tests/test_audit_tests_static.py index e6dc6f642..cc73c0a80 100644 --- a/src/aipass/seedgo/tests/test_audit_tests_static.py +++ b/src/aipass/seedgo/tests/test_audit_tests_static.py @@ -977,13 +977,43 @@ def test_the_retirement_names_a_ruling(self): # pins that the shipped entry carries one. assert all(entry["ruling"] for entry in adapter.RETIRED_GROUPS) + #: The nine groups that existed when the placeholder was retired on + #: 2026-08-29. Frozen deliberately: this is a historical set, and a group + #: added later did not supersede anything. + SUPERSEDING_GROUPS = ( + "static_assertion_shape", + "static_capture_never_read", + "static_coverage_slot", + "static_entry_point_diff", + "static_mock_drift", + "static_no_oracle", + "static_ruff_pt", + "static_self_skip", + "static_unentered_assert", + ) + def test_the_ruling_names_EVERY_group_that_superseded_it(self): # Naming two endpoints and trusting the middle is how a ruling ends up # accounting for less than it retired. The claim is that every species - # the placeholder stood in for is still published - so every live - # static group has to appear by name. + # the placeholder stood in for is still published - so every group that + # took over from it has to appear by name. + # + # CHECKED AGAINST THE FROZEN SET, not against STATIC_GROUPS. It read the + # live tuple until 2026-08-31, when adding static_empty_parametrize made + # it red: a group built two days later did not supersede a placeholder + # retired on 2026-08-29, and the only way to satisfy the old form would + # have been to write it into a historical ruling it had no part in. The + # test was asking a question about today and calling it a question about + # that date. ruling = adapter.RETIRED_GROUPS[0]["ruling"] - assert [group for group in adapter.STATIC_GROUPS if group not in ruling] == [] + assert [group for group in self.SUPERSEDING_GROUPS if group not in ruling] == [] + + def test_every_superseding_group_is_still_live(self): + # The other half of the claim, and the half the frozen set could lose: + # "still published" is only true while each of those nine is actually + # declared. If one is ever retired in turn, this reds and its own + # ruling has to account for it. + assert [group for group in self.SUPERSEDING_GROUPS if group not in adapter.STATIC_GROUPS] == [] def test_no_currently_declared_group_is_also_retired(self): retired = {entry["group"].split(".", 1)[-1] for entry in adapter.RETIRED_GROUPS} @@ -1027,8 +1057,8 @@ def _harness(**overrides): class TestSelfCheck: """Published whether it passes or fails - never a silent pre-flight.""" - def test_all_sixteen_checks_are_published(self): - assert [row["check"] for row in _harness()["checks"]] == list(range(1, 17)) + def test_all_seventeen_checks_are_published(self): + assert [row["check"] for row in _harness()["checks"]] == list(range(1, 18)) def test_a_sound_run_fails_nothing(self): assert _harness()["failed"] == 0 @@ -1045,7 +1075,10 @@ def test_a_missed_canary_fails_check_eleven(self): ) assert [row["check"] for row in block["checks"] if row["status"] == "fail"] == [11] - def test_an_unattributed_change_fails_check_twelve(self): + def test_an_unattributed_change_fails_check_seventeen_not_twelve(self): + """Repointed 2026-08-30 on @trigger's report - the assertion is unchanged, + it just names the row that now owns it. Check 12 reds only on what the + gate SAW the suite write; an unattributed change is check 17's finding.""" proof = { "probed": True, "real_tree_unchanged": False, @@ -1054,9 +1087,11 @@ def test_an_unattributed_change_fails_check_twelve(self): "files_fingerprinted": 12, "diff": {"added": ["/real/logs/operations.jsonl"]}, } - assert 12 in [row["check"] for row in _harness(m10_proof=proof)["checks"] if row["status"] == "fail"] + failed = [row["check"] for row in _harness(m10_proof=proof)["checks"] if row["status"] == "fail"] + assert 17 in failed + assert 12 not in failed, "the suite wrote nothing, so the OBSERVER-FORGERY row must stay green" - def test_a_change_the_live_writer_probe_saw_first_does_not_fail_check_twelve(self): + def test_a_change_the_live_writer_probe_saw_first_does_not_fail_check_seventeen(self): # A live citizen writes its own logs throughout the window. The path is # still published in the diff - it is just not charged to this run. proof = { @@ -1068,7 +1103,7 @@ def test_a_change_the_live_writer_probe_saw_first_does_not_fail_check_twelve(sel "files_fingerprinted": 12, "diff": {"modified": ["/real/api_json/feed_log.json"]}, } - row = [r for r in _harness(m10_proof=proof)["checks"] if r["check"] == 12][0] + row = [r for r in _harness(m10_proof=proof)["checks"] if r["check"] == 17][0] assert row["status"] == "pass" and "feed_log.json" in row["detail"] def test_a_concurrent_write_is_never_removed_from_the_published_diff(self): @@ -1081,7 +1116,7 @@ def test_a_concurrent_write_is_never_removed_from_the_published_diff(self): "files_fingerprinted": 12, "diff": {"modified": ["/real/api_json/feed_log.json"]}, } - row = [r for r in _harness(m10_proof=proof)["checks"] if r["check"] == 12][0] + row = [r for r in _harness(m10_proof=proof)["checks"] if r["check"] == 17][0] assert "1 attributed" in row["detail"] and "probe ran: True" in row["detail"] def test_an_unprobed_fingerprint_fails_checks_one_and_twelve(self): diff --git a/src/aipass/seedgo/tests/test_checkers_batch4.py b/src/aipass/seedgo/tests/test_checkers_batch4.py index d80baec40..9015939d8 100644 --- a/src/aipass/seedgo/tests/test_checkers_batch4.py +++ b/src/aipass/seedgo/tests/test_checkers_batch4.py @@ -483,8 +483,13 @@ def test_unused_function_violation_caught(self, mock_json, tmp_path: Path) -> No "from modules.bloat import used_func\ndef handle_command():\n return used_func()\n", ) result = unused_function_check_branch(str(branch)) - unused_checks = [c for c in result["checks"] if "unused" in c["message"].lower()] + # Keyed on the check's own PAYLOAD, not on prose in its message. This + # used to grep the message for "unused"; when the wording was narrowed + # to "no caller in this branch" (2026-08-31, @memory's overclaim report) + # the test went red without anything about the finding having changed. + unused_checks = [c for c in result["checks"] if c.get("unused")] assert len(unused_checks) > 0 + assert any("never_called_delta" in u["name"] for c in unused_checks for u in c["unused"]) def test_unused_function_bypass_respected(self, mock_json, tmp_path: Path) -> None: """Bypass rules produce score=100.""" diff --git a/src/aipass/seedgo/tests/test_checkers_batch7.py b/src/aipass/seedgo/tests/test_checkers_batch7.py index c8bc606dd..114011226 100644 --- a/src/aipass/seedgo/tests/test_checkers_batch7.py +++ b/src/aipass/seedgo/tests/test_checkers_batch7.py @@ -14,6 +14,8 @@ import pytest from unittest.mock import MagicMock +from aipass.seedgo.apps.handlers.aipass_standards import documentation_check + def _lines(text: str) -> List[str]: """Split text into lines, widening LiteralString to str for pyright.""" @@ -604,6 +606,67 @@ def test_missing_prax_import_fails(self): assert "Prax logger" in result["message"] +class TestCheckPraxLoggerAcceptsBothBindings: + """The standard is about ROUTING; both bindings route identically. + + Raised by @memory 2026-08-30: `from aipass.prax import logger` binds the + logger OBJECT at import and is UNREBINDABLE, so a conftest swapping + sys.modules cannot reach it and real writes escape into @prax's live state + directory. Measured here: ONE daemon test under plain pytest made 23 atomic + writes into the real prax_json/. The module form is rebindable because the + attribute resolves at CALL time. Rejecting it was this checker enforcing a + binding style the standard never asked for. + """ + + def _check(self, source: str): + from aipass.seedgo.apps.handlers.aipass_standards.imports_check import ( + check_prax_logger, + ) + + return check_prax_logger(_lines(source), "/test.py") + + def test_the_rebindable_module_form_passes(self): + assert self._check("from aipass import prax\n")["passed"] is True + + def test_the_object_form_still_passes(self): + assert self._check("from aipass.prax import logger\n")["passed"] is True + + def test_prax_inside_a_multi_name_import_counts(self): + assert self._check("from aipass import cli, prax\n")["passed"] is True + + def test_an_aliased_prax_still_counts(self): + assert self._check("from aipass import prax as p\n")["passed"] is True + + def test_a_sibling_package_is_not_mistaken_for_prax(self): + # `from aipass import cli` must NOT satisfy a prax-logging rule. + assert self._check("from aipass import cli\n")["passed"] is False + + def test_a_name_that_merely_starts_with_prax_is_not_prax(self): + # Substring matching would accept this; the check splits the import + # list and compares whole names. + assert self._check("from aipass import praxis\n")["passed"] is False + + def test_prax_imported_from_somewhere_that_is_not_aipass_does_not_count(self): + # Found by mutation: deleting the "from aipass import " prefix guard + # killed no test, so any package exporting a name `prax` would have + # satisfied an AIPASS logging rule. + assert self._check("from thirdparty import prax\n")["passed"] is False + + def test_a_commented_out_import_does_not_count(self): + assert self._check("# from aipass import prax\n")["passed"] is False + + def test_a_commented_out_OBJECT_form_import_does_not_count_either(self): + # The module form is rejected by the source-package guard even without + # the comment skip, so only the OBJECT form actually exercises it - + # found by mutation, the comment-skip mutant survived until this test. + assert self._check("# from aipass.prax import logger\n")["passed"] is False + + def test_the_failure_message_offers_BOTH_spellings(self): + message = self._check("import os\n")["message"] + assert "from aipass.prax import logger" in message + assert "from aipass import prax" in message + + # -- check_handler_independence (imports) ------------------------------------ @@ -1526,3 +1589,1617 @@ def test_small_function_passes(self): result = check_thin_orchestration(content, "/module.py") assert result is not None assert result["passed"] is True + + +class TestAipassRootIsMatchedAsATokenNotASubstring: + """`AIPASS_ROOTS.json` is a FILENAME, not the AIPASS_ROOT env var. + + Reported by @memory 2026-08-30: their audit dropped to 99% on + `DECLARED_ROOTS = "AIPASS_ROOTS.json"` — a machine-managed file that sits + beside AIPASS_REGISTRY.json because it is the same species. Nothing in that + tree reads an env var of any name. + + They declined to rename the file to satisfy the grep, and they were right + to: renaming a thing so a checker stops shouting is drift-by-linting, and + the name would then have been chosen for a tool rather than a reader. The + check means a TOKEN and was written as a SUBSTRING, so every present and + future `AIPASS_ROOT*` collides. + """ + + def _check(self, line): + from aipass.seedgo.apps.handlers.aipass_standards.imports_check import check_no_aipass_root + + return check_no_aipass_root([line + "\n"], "mod.py", []) + + def test_the_env_var_is_still_caught(self): + assert not self._check('root = os.environ["AIPASS_ROOT"]')["passed"] + + def test_the_env_var_via_getenv_is_still_caught(self): + assert not self._check('root = os.getenv("AIPASS_ROOT")')["passed"] + + def test_a_bare_reference_is_still_caught(self): + assert not self._check("path = AIPASS_ROOT / 'x'")["passed"] + + def test_the_declared_roots_filename_is_NOT_a_violation(self): + """@memory's exact line.""" + assert self._check('DECLARED_ROOTS = "AIPASS_ROOTS.json"')["passed"] + + def test_a_future_AIPASS_ROOT_MAP_is_not_a_violation_either(self): + """The report named this one specifically as the next collision.""" + assert self._check('NAME = "AIPASS_ROOT_MAP.json"')["passed"] + + def test_AIPASS_ROOTS_REGISTRY_is_not_a_violation(self): + assert self._check('NAME = "AIPASS_ROOTS_REGISTRY.json"')["passed"] + + def test_a_LONGER_name_ENDING_in_the_token_is_not_a_violation(self): + """Prefix-only matching would still convict this one.""" + assert self._check('NAME = "MY_AIPASS_ROOT"')["passed"] + + def test_the_env_var_inside_an_fstring_is_still_caught(self): + assert not self._check('msg = f"{AIPASS_ROOT}/x"')["passed"] + + +class TestImportsCheckLineNumbersSurviveDocstringFiltering: + """A reported line number must name the line in the FILE. + + @memory reported the violation at line 40 when the only occurrence is at + line 106 — off by 66, which is more than a docstring's worth of slack and + sends a reader to the wrong place. + + THE CAUSE, and it is bigger than the check they reported: `filter_docstrings` + `continue`s past docstring lines, COMPACTING the list. Every check that runs + on `import_lines` then enumerates a shorter list and reports an index into + it — so `check_no_sys_path`, `check_prax_logger`, `check_import_order` and + `check_no_bare_imports` all carry the same defect. Blanking the lines + instead of dropping them keeps every index aligned with the file. + """ + + def test_filter_docstrings_preserves_the_line_count(self): + from aipass.seedgo.apps.handlers.aipass_standards.imports_check import filter_docstrings + + source = ['"""doc\n', "more doc\n", '"""\n', "import os\n"] + assert len(filter_docstrings(source)) == len(source) + + def test_the_docstring_body_is_still_neutralised(self): + """Preserving the line must not un-filter its content.""" + from aipass.seedgo.apps.handlers.aipass_standards.imports_check import filter_docstrings + + out = filter_docstrings(['"""\n', "AIPASS_ROOT lives here\n", '"""\n', "import os\n"]) + assert "AIPASS_ROOT" not in "".join(out) + + def test_a_violation_after_a_docstring_reports_its_REAL_line(self): + from aipass.seedgo.apps.handlers.aipass_standards.imports_check import ( + check_no_aipass_root, + filter_docstrings, + ) + + source = ['"""\n', "a\n", "b\n", "c\n", '"""\n', 'x = os.environ["AIPASS_ROOT"]\n'] + result = check_no_aipass_root(filter_docstrings(source), "mod.py", []) + assert not result["passed"] + assert "line 6" in result["message"], result["message"] + + def test_the_real_reported_file_now_passes_end_to_end(self): + """@memory's actual file, through the real entry point.""" + from pathlib import Path + + from aipass.seedgo.apps.handlers.aipass_standards import imports_check + + target = Path(__file__).resolve().parents[2] / "memory" / "apps" / "handlers" / "monitor" / "registry_scope.py" + if not target.exists(): + import pytest + + pytest.skip("registry_scope.py not on disk") + rows = imports_check.check_module(str(target))["checks"] + root_rows = [r for r in rows if r["name"] == "No AIPASS_ROOT"] + assert root_rows and root_rows[0]["passed"], root_rows + + +class TestJsonStructureExemptsDeclarationOnlyModules: + """ "Every module must log operations" is a rule about modules that PERFORM them. + + Raised by @drone 2026-08-31 on drone/apps/handlers/exceptions.py — 86 lines, + ten exception classes, zero functions. It USED to pass, by carrying + `log_operation("exceptions_loaded")` at module level. That turned out to be a + real defect: running at import, it fired during pytest COLLECTION, before any + fixture existed, so no seam could intercept it. Removing it took the file + from passing to 0. + + So the check was REWARDING the defect, and both routes to green were worse + than the violation: put the import-time write back, or add a function to a + file of pure class definitions purely so there is somewhere to call + log_operation from — dead code written to satisfy a grep, which + unused_function would then flag. + + A module that defines no callable code performs no operations, so it has + nothing to log. The exemption is keyed on that structural fact and nothing + else: ONE method anywhere, and the module is back in scope. + """ + + def _score(self, source, tmp_path): + from aipass.seedgo.apps.handlers.aipass_standards import json_structure_check + + # MUST land under apps/handlers or the checker returns not-applicable + # on the PATH and every assertion below passes for the wrong reason — + # which is exactly what the first cut of this class did. + target = tmp_path / "apps" / "handlers" / "routing" / "mod.py" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(source, encoding="utf-8") + return json_structure_check.check_module(str(target)) + + DECLARATIONS_ONLY = '"""Doc."""\n\n\nclass RoutingError(Exception):\n """Boom."""\n\n\nclass BranchNotFound(RoutingError):\n pass\n' + + def test_a_module_of_pure_exception_classes_is_not_applicable(self, tmp_path): + result = self._score(self.DECLARATIONS_ONLY, tmp_path) + assert result["score"] == 100, result["checks"] + + def test_the_exemption_is_PUBLISHED_not_a_silent_pass(self, tmp_path): + """A skip nobody can read is indistinguishable from a check that ran.""" + result = self._score(self.DECLARATIONS_ONLY, tmp_path) + text = " ".join(c["message"] for c in result["checks"]).lower() + assert "declar" in text or "no operations" in text, result["checks"] + + def test_ONE_method_puts_the_module_back_in_scope(self, tmp_path): + source = '"""Doc."""\n\n\nclass Thing:\n def do(self):\n return 1\n' + assert self._score(source, tmp_path)["score"] < 100 + + def test_a_module_level_function_is_still_in_scope(self, tmp_path): + source = '"""Doc."""\n\n\ndef work():\n return 1\n' + assert self._score(source, tmp_path)["score"] < 100 + + def test_a_dataclass_with_no_methods_is_exempt(self, tmp_path): + source = '"""Doc."""\nfrom dataclasses import dataclass\n\n\n@dataclass\nclass Point:\n x: int\n' + assert self._score(source, tmp_path)["score"] == 100 + + def test_constants_alone_do_not_earn_the_exemption_back(self, tmp_path): + """Declarations plus data is still declarations.""" + source = '"""Doc."""\n\nLIMIT = 10\n\n\nclass E(Exception):\n pass\n' + assert self._score(source, tmp_path)["score"] == 100 + + def test_an_UNPARSEABLE_file_does_not_earn_the_exemption(self, tmp_path): + """A syntax error is not evidence of purity. Answering "no callable + code" for a file we could not read hands out the exemption on + ignorance — and the first cut of these pins let that mutant live.""" + assert self._score('"""Doc."""\n\nclass Broken(\n', tmp_path)["score"] < 100 + + def test_the_REAL_drone_file_is_exempt(self, tmp_path): + from pathlib import Path + + from aipass.seedgo.apps.handlers.aipass_standards import json_structure_check + + target = Path(__file__).resolve().parents[2] / "drone" / "apps" / "handlers" / "exceptions.py" + if not target.exists(): + import pytest + + pytest.skip("drone exceptions.py not on disk") + assert json_structure_check.check_module(str(target))["score"] == 100 + + +class TestUnusedFunctionSaysWhatItActuallyMeasured: + """The check measures "no caller in THIS branch" and said "unused". + + @memory 2026-08-31: `entry_limits.changed_entries()` was reported unused. It + is called by @hooks at security/edit_gate.py:496 through a dynamic + importlib import — a cross-branch consumer a same-branch AST scan cannot see + in principle. Deleting it would disable the entry-cap half of the write gate + for the whole fleet. + + The danger is precisely that the check is USEFUL: @memory had already acted + on it correctly once the same night and dropped a genuinely dead helper. An + agent that trusts it will eventually delete a real cross-branch entry point + to get a green audit, and the audit will go green. + + They asked for the cheapest of three options and were right about which: + the measurement is sound, only the WORD overclaims. Same species as this + pack's AIPASS_ROOT substring bug — the checker stating more than it measured. + """ + + def _report(self, tmp_path, monkeypatch): + from aipass.seedgo.apps.handlers.aipass_standards import unused_function_check + + # This module's autouse _mock_infrastructure fixture replaces + # bypass.ignore_handler with a MagicMock, so is_seedgo_ignored() returns + # a TRUTHY Mock and the collector silently skips every file — the report + # then reads "No .py files found in branch" and any assertion about the + # message passes or fails for reasons unrelated to the message. A + # stand-in more generous than the real function, which is the exact trap + # @memory described on 2026-08-30. Pinned back to the real answer here. + monkeypatch.setattr(unused_function_check, "is_seedgo_ignored", lambda *a, **k: False) + monkeypatch.setattr(unused_function_check, "load_ignore_entries", lambda *a, **k: []) + + # A neutral branch root INSIDE tmp_path: pytest names tmp_path after the + # test, and the collector's skip set is matched against every path part. + root = tmp_path / "branchroot" + pkg = root / "apps" / "handlers" + pkg.mkdir(parents=True) + (pkg / "mod.py").write_text("def never_called_here():\n return 1\n", encoding="utf-8") + return unused_function_check.check_branch(str(root)) + + def test_the_message_does_not_claim_the_function_is_unused(self, tmp_path, monkeypatch): + report = self._report(tmp_path, monkeypatch) + blob = str(report).lower() + assert "no caller in this branch" in blob, report + + def test_the_message_names_the_limit_of_the_scan(self, tmp_path, monkeypatch): + """A reader must be able to tell that cross-branch callers were not checked.""" + blob = str(self._report(tmp_path, monkeypatch)).lower() + # NOT an `or` over synonyms: the first cut accepted either phrase, so a + # mutant deleting the cross-branch clause kept passing on "this branch" + # alone. Both halves of the disclosure are pinned. + assert "cross-branch" in blob + assert "importlib" in blob + assert "confirm before deleting" in blob + + +class TestCliCheckAllowsRelayingACapturedSubprocessStream: + """A router passing a child's bytes through is not "your output, undecorated". + + Raised via @devpulse 2026-08-31 for drone.py, which has eight of these. The + dispatch guessed they were the `--json` machine surface; they are something + simpler and better justified — every one is + `sys.stdout.write(result.stdout)` / `sys.stderr.write(result.stderr)`, + drone relaying a completed subprocess's captured output verbatim. + + The `cli` rule means "route YOUR OWN output through Rich". Rich would + interpret markup in, wrap, and re-style bytes drone did not author, which is + precisely what a router must not do. The checker was grepping a VERB where + the rule asks an AUTHORSHIP question — the same species as this pack's + AIPASS_ROOT substring bug and the json_structure and unused_function items + ruled the same night. + + The exemption is narrow by construction and keyed on the ARGUMENT, so it + cannot be borrowed: writing a literal, an f-string, or a value you built is + still a violation. It also requires the streams to CORRESPOND — writing a + captured stderr to stdout stays red, because that is a real routing bug this + check should keep catching. + """ + + def _lines(self, source): + from aipass.seedgo.apps.handlers.aipass_standards import cli_check + + return [i for i, line in enumerate(source.splitlines(), 1) if cli_check._is_raw_write_violation(line)] + + def test_relaying_captured_stdout_attribute_is_allowed(self): + assert self._lines(" sys.stdout.write(result.stdout)") == [] + + def test_relaying_captured_stdout_subscript_is_allowed(self): + assert self._lines(' sys.stdout.write(result["stdout"])') == [] + + def test_relaying_captured_stderr_to_stderr_is_allowed(self): + assert self._lines(" sys.stderr.write(result.stderr)") == [] + + def test_a_literal_is_STILL_a_violation(self): + assert self._lines(' sys.stdout.write("hello")') != [] + + def test_an_fstring_is_STILL_a_violation(self): + assert self._lines(' sys.stdout.write(f"{count} done")') != [] + + def test_a_value_you_built_is_STILL_a_violation(self): + assert self._lines(" sys.stdout.write(rendered_table)") != [] + + def test_CROSSED_streams_stay_a_violation(self): + """Captured stderr written to stdout is a routing bug, not a relay.""" + assert self._lines(" sys.stdout.write(result.stderr)") != [] + assert self._lines(' sys.stderr.write(result["stdout"])') != [] + + def test_a_commented_out_relay_is_not_examined_at_all(self): + assert self._lines(" # sys.stdout.write(result.stdout)") == [] + + def test_a_commented_out_VIOLATION_is_not_flagged(self): + """The relay case above is inert either way — a mutant that stopped + stripping comments survived it. This one only passes if comments are + actually stripped.""" + assert self._lines(' # sys.stdout.write("hello")') == [] + + def test_a_trailing_comment_does_not_hide_a_real_violation(self): + assert self._lines(' sys.stdout.write("hello") # relay') != [] + + def test_the_REAL_drone_entry_point_has_no_raw_write_violations(self): + from pathlib import Path + + from aipass.seedgo.apps.handlers.aipass_standards import cli_check + + target = Path(__file__).resolve().parents[2] / "drone" / "apps" / "drone.py" + if not target.exists(): + import pytest + + pytest.skip("drone.py not on disk") + lines = target.read_text(encoding="utf-8").splitlines() + assert [i for i, line in enumerate(lines, 1) if cli_check._is_raw_write_violation(line)] == [] + + +class TestJsonStructureExemptsPreLoggingBootstrapModules: + """The pre-logging bootstrap class @prax reported (2026-08-31). + + A module the logging substrate itself imports cannot import json_handler + back: log_operation() WRITES, so wiring it there puts a file write on the + import path of every branch — the ungateable write that fires during pytest + COLLECTION. Two clauses decide it, both MEASURED here: no aipass import of + any kind, AND the logging chain's own imports reach the module. Either + clause alone hands out the exemption far too widely. + """ + + def _universe(self, tmp_path): + """A miniature src/aipass with a logger that imports one bootstrap module.""" + root = tmp_path / "src" / "aipass" + prax = root / "prax" / "apps" + (prax / "modules").mkdir(parents=True) + (prax / "handlers").mkdir(parents=True) + (prax / "modules" / "logger.py").write_text( + "from aipass.prax.apps.handlers.boot import find_root\ndef get_system_logger():\n return find_root()\n", + encoding="utf-8", + ) + return root + + def _check(self, path): + from aipass.seedgo.apps.handlers.aipass_standards import json_structure_check + + return json_structure_check._is_prelogging_bootstrap(path, path.read_text(encoding="utf-8")) + + def test_a_stdlib_only_module_the_logger_imports_is_exempt(self, tmp_path): + root = self._universe(tmp_path) + boot = root / "prax" / "apps" / "handlers" / "boot.py" + boot.write_text( + "import logging\nfrom pathlib import Path\n\ndef find_root():\n return Path(__file__).parent\n", + encoding="utf-8", + ) + assert self._check(boot) is True + + def test_the_same_module_loses_the_exemption_the_moment_it_imports_aipass(self, tmp_path): + root = self._universe(tmp_path) + boot = root / "prax" / "apps" / "handlers" / "boot.py" + boot.write_text( + "from pathlib import Path\n" + "from aipass.prax.apps.handlers.json import json_handler\n\n" + "def find_root():\n return Path(__file__).parent\n", + encoding="utf-8", + ) + assert self._check(boot) is False + + def test_an_aipass_import_inside_a_function_still_disqualifies(self, tmp_path): + """The dependency is taken wherever it is written. A module that can reach + aipass from inside a function can reach json_handler the same way.""" + root = self._universe(tmp_path) + boot = root / "prax" / "apps" / "handlers" / "boot.py" + boot.write_text( + "from pathlib import Path\n\n" + "def find_root():\n" + " from aipass.prax.apps.handlers.json import json_handler\n" + " return Path(__file__).parent\n", + encoding="utf-8", + ) + assert self._check(boot) is False + + def test_a_stdlib_only_module_the_chain_never_reaches_is_NOT_exempt(self, tmp_path): + """Clause 2 is what keeps the exemption from being free — 79 fleet modules + import nothing from aipass, and only 9 are in the chain.""" + root = self._universe(tmp_path) + lonely = root / "prax" / "apps" / "handlers" / "lonely.py" + lonely.write_text( + "import re\n\ndef parse(text):\n return re.findall(r'x', text)\n", + encoding="utf-8", + ) + assert self._check(lonely) is False + + def test_an_unparseable_file_is_not_exempt(self, tmp_path): + """_aipass_imports answers [] for a file it could not read, which would + otherwise read as 'holds no aipass import'. No exemption on ignorance.""" + root = self._universe(tmp_path) + boot = root / "prax" / "apps" / "handlers" / "boot.py" + boot.write_text("def find_root(:\n pass\n", encoding="utf-8") + assert self._check(boot) is False + + def test_a_file_outside_any_aipass_source_root_is_not_exempt(self, tmp_path): + loose = tmp_path / "loose.py" + loose.write_text("import re\n\ndef parse(t):\n return t\n", encoding="utf-8") + assert self._check(loose) is False + + def test_the_REAL_prax_repo_root_scores_100(self): + """The live case that prompted the rule — pinned against the real tree so a + regression shows up as prax going red again, not as a green unit test.""" + from pathlib import Path + + import pytest + + from aipass.seedgo.apps.handlers.aipass_standards import json_structure_check + + target = Path(__file__).resolve().parents[2] / "prax" / "apps" / "handlers" / "repo_root.py" + if not target.exists(): + pytest.skip("prax/apps/handlers/repo_root.py not on disk") + result = json_structure_check.check_module(str(target)) + assert result["score"] == 100 + assert "bootstrap" in result["checks"][0]["message"] + + def test_the_REAL_memory_repo_root_is_NOT_exempt(self): + """Same filename, same purpose, different position: memory's copy imports + json_handler and the prax logger, so it is a consumer and stays in scope.""" + from pathlib import Path + + import pytest + + from aipass.seedgo.apps.handlers.aipass_standards import json_structure_check + + target = Path(__file__).resolve().parents[2] / "memory" / "apps" / "handlers" / "repo_root.py" + if not target.exists(): + pytest.skip("memory/apps/handlers/repo_root.py not on disk") + assert json_structure_check._is_prelogging_bootstrap(target, target.read_text(encoding="utf-8")) is False + + +class TestAutoDetectionAsksAboutThePublicSurface: + """The auto-detection rule means "a caller should not have to name itself", + and that is a question about a handler's PUBLISHED functions. It was measured + by grepping every `def ...(... module_name` in the file, so a private helper + that resolves a dotted name it was handed — with no caller to detect — was + convicted on the parameter NAME (found by dogfooding this checker on its own + tree, 2026-08-31).""" + + def _check(self, content): + from aipass.seedgo.apps.handlers.aipass_standards import handlers_check + + return handlers_check.check_auto_detection(content) + + def test_a_public_function_taking_module_name_still_needs_auto_detection(self): + result = self._check("def log_operation(operation, data, module_name=None):\n pass\n") + assert result is not None and result["passed"] is False + + def test_a_private_helper_taking_module_name_is_not_the_rules_business(self): + assert self._check("def _module_file(module_name, source_root):\n return None\n") is None + + def test_a_keyword_only_module_name_on_a_public_function_still_counts(self): + result = self._check("def write(data, *, module_name=None):\n pass\n") + assert result is not None and result["passed"] is False + + def test_a_public_function_with_auto_detection_passes(self): + content = ( + "import inspect\n\n" + "def log_operation(operation, data, module_name=None):\n" + " frame = inspect.stack()[1]\n return frame\n" + ) + result = self._check(content) + assert result is not None and result["passed"] is True + + def test_a_file_with_no_module_name_parameter_is_silent(self): + assert self._check("def check(path):\n return path\n") is None + + def test_the_word_in_a_docstring_or_a_call_does_not_convict(self): + """The old text scan keyed on `def name(... module_name`; the AST keys on + the parameter itself, so prose and call sites cannot trigger it.""" + content = ( + 'def resolve(path):\n """Resolves a module_name for the caller."""\n return log(module_name=path)\n' + ) + assert self._check(content) is None + + def test_an_unparseable_file_falls_back_to_the_text_scan(self): + """A file we could not read is not evidence the parameter is absent.""" + result = self._check("def log_operation(operation, module_name=None):\n pass\n\ndef broken(:\n") + assert result is not None and result["passed"] is False + + +class TestSilentCatchAllowsClassifyingAnExceptionIntoAValue: + """@spawn's report (2026-08-31): `except FileNotFoundError: return "absent"` + was flagged as a swallow. It is the opposite — the exception's information + becomes the return value and the caller asserts on it. A logger call in a + test helper would route production logging out of a suite, which is what the + hygiene lane exists to stop, so two standards were pulling opposite ways. + + Two clauses, both measured: a SPECIFIC exception type (bare / Exception says + only "it failed"), and a body of exactly one `return `. + """ + + def _lines(self, tmp_path, source): + from aipass.seedgo.apps.handlers.aipass_standards import silent_catch_check + + target = tmp_path / "probe.py" + target.write_text(source, encoding="utf-8") + result = silent_catch_check.check_module(str(target)) + return result["checks"][0]["message"], result["checks"][0]["passed"] + + def test_a_named_exception_returning_a_constant_is_not_a_silent_catch(self, tmp_path): + source = ( + "def final_state(path):\n" + " try:\n return path.read_text()\n" + " except FileNotFoundError:\n return 'absent'\n" + ) + _message, passed = self._lines(tmp_path, source) + assert passed is True + + def test_a_tuple_of_named_exceptions_still_qualifies(self, tmp_path): + source = ( + "def verdict(run):\n" + " try:\n return run()\n" + " except (AssertionError, ValueError):\n return 'FAILED'\n" + ) + _message, passed = self._lines(tmp_path, source) + assert passed is True + + def test_a_dotted_exception_name_qualifies(self, tmp_path): + """pytest.skip.Exception is the shape @spawn actually catches.""" + source = ( + "import pytest\n\n" + "def verdict(run):\n" + " try:\n return run()\n" + " except pytest.skip.Exception:\n return 'SKIPPED'\n" + ) + _message, passed = self._lines(tmp_path, source) + assert passed is True + + def test_catching_Exception_broadly_keeps_its_finding(self, tmp_path): + """ "It failed" is not a classification: the type carries no meaning, so + the caller learns nothing the return value could not have hidden.""" + source = "def f(run):\n try:\n return run()\n except Exception:\n return False\n" + _message, passed = self._lines(tmp_path, source) + assert passed is False + + def test_a_bare_except_keeps_its_finding(self, tmp_path): + source = "def f(run):\n try:\n return run()\n except:\n return None\n" + _message, passed = self._lines(tmp_path, source) + assert passed is False + + def test_a_named_exception_that_only_passes_keeps_its_finding(self, tmp_path): + """No value leaves the handler, so nothing was classified.""" + source = "def f(run):\n try:\n run()\n except FileNotFoundError:\n pass\n" + _message, passed = self._lines(tmp_path, source) + assert passed is False + + def test_a_named_exception_that_continues_keeps_its_finding(self, tmp_path): + source = ( + "def f(items):\n for i in items:\n try:\n i()\n" + " except FileNotFoundError:\n continue\n" + ) + _message, passed = self._lines(tmp_path, source) + assert passed is False + + def test_extra_statements_before_the_return_keep_the_finding(self, tmp_path): + """The shape is a CONVERSION, not a body that happens to end in one — + work done on the way out is work the finding should still be read for.""" + source = ( + "def f(run, state):\n try:\n return run()\n" + " except FileNotFoundError:\n state['seen'] = True\n return 'absent'\n" + ) + _message, passed = self._lines(tmp_path, source) + assert passed is False + + def test_a_computed_return_is_not_a_constant(self, tmp_path): + source = ( + "def f(run, fallback):\n try:\n return run()\n" + " except FileNotFoundError:\n return fallback()\n" + ) + _message, passed = self._lines(tmp_path, source) + assert passed is False + + def test_a_return_carrying_the_caught_exception_qualifies(self, tmp_path): + """`return ("FAILED", str(exc))` carries MORE than a constant does. + Keying on ast.Constant alone flagged the better version of the pattern — + found by running the rule against @spawn's real file, not my examples.""" + source = ( + "def verdict(run):\n" + " try:\n return ('PASSED', '')\n" + " except AssertionError as exc:\n return ('FAILED', str(exc))\n" + ) + _message, passed = self._lines(tmp_path, source) + assert passed is True + + def test_a_computed_return_that_never_mentions_the_exception_stays_flagged(self, tmp_path): + """Binding `as exc` is not enough — the value has to carry it.""" + source = ( + "def f(run, fallback):\n try:\n return run()\n" + " except FileNotFoundError as exc:\n return fallback()\n" + ) + _message, passed = self._lines(tmp_path, source) + assert passed is False + + def test_counting_the_exception_and_continuing_stays_flagged(self, tmp_path): + """A counter may be asserted on later or may be read by nobody, and this + checker cannot see which — so the finding stands and the reader rules.""" + source = ( + "def f(items):\n missing = 0\n for i in items:\n try:\n i()\n" + " except FileNotFoundError:\n missing += 1\n continue\n return missing\n" + ) + _message, passed = self._lines(tmp_path, source) + assert passed is False + + +class TestSilentCatchAllowsAHandlerThatReportsToAStream: + """@commons' report (2026-08-31): a handler that PRINTS the failure was + flagged as swallowing it. + + "Silently" is this standard's own word. Their entry point repairs + sys.path[0] before any cross-branch import — @prax's logger is imported + after that block, and importing it earlier is exactly what the repair exists + to make safe — so the only instrument in that window is stderr, and the + handler used it. The cure the checker demanded could not be written while + the code already did what the standard asks. + + This is a correction against the definition, not a window exemption: it + holds anywhere, and the CLI standard keeps asking separately whether the + stream was the RIGHT instrument. Measured before landing: 1 handler in the + fleet matches. + """ + + def _passed(self, tmp_path, source): + from aipass.seedgo.apps.handlers.aipass_standards import silent_catch_check + + target = tmp_path / "probe.py" + target.write_text(source, encoding="utf-8") + return silent_catch_check.check_module(str(target))["checks"][0]["passed"] + + def test_a_handler_writing_to_stderr_is_not_silent(self, tmp_path): + source = ( + "import sys\n\n" + "try:\n x = resolve()\n" + "except OSError as exc:\n sys.stderr.write(f'cannot resolve: {exc}\\n')\n" + ) + assert self._passed(tmp_path, source) is True + + def test_a_handler_writing_to_stdout_is_not_silent_either(self, tmp_path): + """The standard's word is 'silently', not 'stderr'. A report on stdout + is a worse choice and a separate standard's argument, but it is still + not silence, and this checker must not decide that question.""" + source = "import sys\n\ntry:\n go()\nexcept OSError:\n sys.stdout.write('failed\\n')\n" + assert self._passed(tmp_path, source) is True + + def test_the_write_must_be_to_a_STREAM_not_any_write_method(self, tmp_path): + """Negative control on the clause: ``handle.write(...)`` puts the + failure in a file nobody is watching. Keying on the method name alone + would have exempted every handler that quietly writes to disk. + + The handler deliberately does NOT bind the exception. A later clause + (hands_the_exception_on) clears ``handle.write(str(exc))`` on its own + and separately measured grounds — the object is not dropped — so binding + it here would test that clause instead of this one and this pin would + pass while the stream rule rotted underneath it. + """ + source = "def f(handle):\n try:\n go()\n except OSError:\n handle.write('failed')\n" + assert self._passed(tmp_path, source) is False + + def test_a_handler_that_reports_nothing_keeps_its_finding(self, tmp_path): + source = "try:\n go()\nexcept OSError:\n pass\n" + assert self._passed(tmp_path, source) is False + + def test_the_real_reported_shape_clears(self, tmp_path): + """@commons' own block, transcribed. The pin the report actually earns.""" + source = ( + "import sys\n" + "from pathlib import Path\n\n" + "_script_dirs = [str(Path(__file__).parent)]\n" + "try:\n _script_dirs.append(str(Path(__file__).resolve().parent))\n" + "except OSError as _exc:\n" + " sys.stderr.write(f'[commons] Cannot resolve {__file__} ({type(_exc).__name__})\\n')\n" + ) + assert self._passed(tmp_path, source) is True + + +class TestCliAllowsARawWriteBeforeTheConsoleExists: + """@commons' second finding, the same block one line down: the CLI checker + demanded ``console.print()`` in the window BEFORE the module imports the + console. + + Unsatisfiable, not merely wrong — and a checker that convicts on an + unsatisfiable clause teaches branches to take waivers. Two AST clauses, and + the PAIR is what keeps it narrow: the module must import the console at all, + and the write must lexically precede that import. Measured across the fleet + 2026-08-31: the pair clears exactly one site; clause 1 alone (a module with + no console) clears 0 today in this checker's scope and an unbounded number + of future ones, so it was rejected on the measurement rather than on taste. + """ + + CONSOLE_IMPORT = "from aipass.cli.apps.modules import console\n" + + def _passed(self, tmp_path, source): + from aipass.seedgo.apps.handlers.aipass_standards import cli_check + + target = tmp_path / "modules" / "probe.py" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(source, encoding="utf-8") + result = cli_check.check_print_usage(source, source.split("\n"), str(target)) + return result is None or result["passed"] + + def test_a_write_above_the_console_import_is_allowed(self, tmp_path): + source = "import sys\n\nsys.stderr.write('bootstrap trouble\\n')\n\n" + self.CONSOLE_IMPORT + assert self._passed(tmp_path, source) is True + + def test_a_write_below_the_console_import_keeps_its_finding(self, tmp_path): + """The window closes at the import. Everything after it has Rich.""" + source = "import sys\n\n" + self.CONSOLE_IMPORT + "\nsys.stderr.write('late\\n')\n" + assert self._passed(tmp_path, source) is False + + def test_a_write_on_the_import_line_itself_keeps_its_finding(self, tmp_path): + """The boundary, pinned because a mutant walked through it. + + ``<`` and ``<=`` differ on exactly one line and both read fine. The + console exists once that statement has run, so a write sharing the line + is after it, not before it — the window is STRICTLY prior. Found by a + surviving mutant, not by reading the code. + """ + source = "import sys\n\n" + self.CONSOLE_IMPORT.rstrip("\n") + "; sys.stderr.write('same line\\n')\n" + assert self._passed(tmp_path, source) is False + + def test_a_module_that_never_imports_the_console_keeps_its_finding(self, tmp_path): + """Clause 1, pinned as a REFUSAL. Dropping it looks like a + simplification — 'no console, nothing to use' — and it would hand every + console-less module a blanket pass for raw writes.""" + source = "import sys\n\nsys.stderr.write('no console anywhere in this file\\n')\n" + assert self._passed(tmp_path, source) is False + + def test_an_unparseable_file_gets_no_window(self, tmp_path): + """Ignorance is not evidence of a bootstrap: a file the checker cannot + parse keeps every finding rather than being exempted by the failure.""" + from aipass.seedgo.apps.handlers.aipass_standards import cli_check + + assert cli_check._console_import_line("def broken(:\n") is None + + def test_the_real_reported_shape_clears(self, tmp_path): + """@commons' entry point, transcribed: the sys.path repair, its stderr + report, then the cross-branch imports the repair exists to make safe.""" + source = ( + "import sys\n" + "from pathlib import Path\n\n" + "_script_dirs = [str(Path(__file__).parent)]\n" + "try:\n _script_dirs.append(str(Path(__file__).resolve().parent))\n" + "except OSError as _exc:\n" + " sys.stderr.write('[commons] cannot resolve\\n')\n" + "for _d in _script_dirs:\n if _d in sys.path:\n sys.path.remove(_d)\n\n" + "from aipass.prax.apps.modules.logger import system_logger as logger # noqa: E402\n" + "from aipass.cli.apps.modules import console # noqa: E402\n" + ) + assert self._passed(tmp_path, source) is True + + +class TestAutoDetectionNoLongerMandatesTheDeadCwdDefect: + """@prax's report (2026-08-31): the checker's proof of auto-detection was + the literal string ``inspect.stack()``, and its failure message said "use + inspect.stack()". + + That call is the Windows dead-cwd defect the fleet spent this week removing + — it reaches an unguarded ``os.path.realpath`` in ``inspect.getmodule``, and + ``ntpath.realpath`` reads ``os.getcwd()`` before checking anything. @prax + cured their own site and the audit dropped the file to 66% and told them to + put it back. They renamed a helper instead, which fixed prax and left the + checker aimed at the next branch to cure — @spawn counted 16 of 18 branches + still carrying the call. + + Measured before landing: ZERO fleet files passed via the inspect.stack() + clause, so widening the acceptance changed nobody's score. The harm was + entirely prospective, which is exactly when a checker is cheapest to fix. + """ + + def _check(self, content): + from aipass.seedgo.apps.handlers.aipass_standards.handlers_check import ( + check_auto_detection, + ) + + return check_auto_detection(content) + + def test_the_cured_shape_passes_without_a_specially_named_helper(self): + """@prax's blocking case. sys._getframe answers the same question and + cannot die on a Windows box with no working directory.""" + content = ( + "import sys\n\n" + "def log_operation(op, module_name=None):\n" + " if module_name is None:\n" + " module_name = sys._getframe(1).f_code.co_name\n" + " return module_name\n" + ) + assert self._check(content)["passed"] is True + + def test_inspect_stack_still_counts_as_an_answer(self): + """Widening, not a ban. Whether inspect.stack() is the RIGHT mechanism + is @drone's dead-cwd sweep, a different standard; this one only asks + whether the caller is detected at all. A checker that answered both + questions would convict a branch mid-migration for the wrong reason.""" + content = ( + "import inspect\n\n" + "def log_operation(op, module_name=None):\n" + " if module_name is None:\n" + " module_name = inspect.stack()[1]\n" + " return module_name\n" + ) + assert self._check(content)["passed"] is True + + def test_the_mechanism_must_be_CALLED_not_merely_mentioned(self): + """The substring hole, closed. A file whose only ``inspect.stack()`` is + in a docstring was passing — and @drone hit the mirror image the same + morning, a text BAN convicting the docstring that explained the cure. + A string rule is too broad and too narrow at once.""" + content = ( + '"""We used to call inspect.stack() here — see the dead-cwd note."""\n' + "import inspect\n\n" + "def log_operation(op, module_name=None):\n return module_name\n" + ) + assert self._check(content)["passed"] is False + + def test_a_required_module_name_is_not_applicable(self): + """Nothing to detect: every caller supplies it at every call site, so + the prescribed frame walk would be dead code. Three @prax files were red + for this.""" + assert self._check("def run(module_name: str):\n return module_name\n") is None + + def test_an_optional_module_name_is_still_in_scope(self): + """Negative control on the clause above — the narrowing must not + exempt the population the standard is actually about.""" + assert self._check("def run(module_name=None):\n return module_name\n") is not None + + def test_a_keyword_only_module_name_with_a_default_is_in_scope(self): + """Boundary: the default lives in kw_defaults, a different list. Reading + only positional defaults would silently exempt every keyword-only API.""" + assert self._check("def run(*, module_name=None):\n return module_name\n") is not None + + def test_a_keyword_only_module_name_without_a_default_is_not(self): + content = "def run(*, module_name: str):\n return module_name\n" + assert self._check(content) is None + + def test_the_failure_message_names_the_blind_spot(self): + """@trigger's events/warning_logged.py carries a ``module_name`` that is + an event PAYLOAD field — the module that logged a warning — while its + caller is the event bus. Auto-detecting there would be wrong, and the + checker reads the NAME and cannot tell them apart. I have no structural + measure for that, so the finding states what it measured instead of + carrying a clause I cannot defend.""" + result = self._check("def run(module_name=None):\n return module_name\n") + assert "event payload" in result["message"] + + def test_other_frame_walk_spellings_are_accepted_too(self): + """The standard's question is whether the caller is detected, never + which library spells it.""" + for call in ("inspect.currentframe()", "traceback.extract_stack()", "inspect.getouterframes(None)"): + content = f"import inspect, traceback\n\ndef run(module_name=None):\n x = {call}\n return x\n" + assert self._check(content)["passed"] is True, call + + +class TestAHandlerThatGuardsItsOwnDiagnosticIsNotSilent: + """@daemon and @canary, 2026-08-31, the same site from two sides. + + The fleet's ratified dead-cwd cure wraps its own diagnostic: + + try: + logger.debug(...) + except Exception as inner: + _retain("logger", inner) + + @daemon's own pin caught their first cut logging from OUTSIDE that try, and + the world that reaches this code is a machine whose filesystem cannot answer + a basic question — @prax's logger construction reads the working directory, + so "the logger is also down" is the SAME world. The literal fix the checker + demanded was to add a logger call to the handler for a logger failure: the + defect their pin had just caught, put back to satisfy a score. + + @canary MEASURED the prescription instead of arguing with it — applying it + verbatim made their branch unimportable. + + Measured before landing: 6 handlers in the fleet match, in 6 branches, and + every one is this cure's diagnostic guard. + """ + + def _passed(self, tmp_path, source): + from aipass.seedgo.apps.handlers.aipass_standards import silent_catch_check + + target = tmp_path / "probe.py" + target.write_text(source, encoding="utf-8") + return silent_catch_check.check_module(str(target))["checks"][0]["passed"] + + def test_a_pass_guarding_a_logger_call_is_not_silent(self, tmp_path): + source = "def f(logger, exc):\n try:\n logger.debug('failed: %s', exc)\n except Exception:\n pass\n" + assert self._passed(tmp_path, source) is True + + def test_a_pass_guarding_a_stream_report_is_not_silent(self, tmp_path): + """@canary's shape: the write composes its message with type(exc), and + counting THAT call as an effect is what made the first cut miss it.""" + source = ( + "import sys\n\n" + "def f(name, exc, reported):\n" + " try:\n" + " if name not in reported:\n" + " reported.add(name)\n" + " sys.stderr.write(f'{name}: {type(exc).__name__}: {exc}\\n')\n" + " except OSError:\n pass\n" + ) + assert self._passed(tmp_path, source) is True + + def test_a_pass_guarding_REAL_WORK_keeps_its_finding(self, tmp_path): + """The clause is on what the block DOES, not on the handler being empty. + A try that computes something and a bare pass is the original defect.""" + source = "def f(path):\n try:\n path.unlink()\n except OSError:\n pass\n" + assert self._passed(tmp_path, source) is False + + def test_a_report_STANDING_BESIDE_real_work_does_not_buy_the_exemption(self, tmp_path): + """The purity clause, pinned because a mutant walked through it. + + Dropping ``all(...)`` and keeping only ``any(...)`` leaves a rule that a + single log line can buy: wrap anything at all, add a logger call, and + the handler goes quiet. The block has to be a diagnostic and NOTHING + else. Found by mutation, not by reading. + """ + source = ( + "def f(logger, path):\n try:\n" + " logger.debug('about to unlink')\n path.unlink()\n" + " except OSError:\n pass\n" + ) + assert self._passed(tmp_path, source) is False + + def test_a_file_handle_write_is_not_a_report(self, tmp_path): + """The stream qualification, pinned on THIS clause rather than on + silent_catch's own helper — a mutant that accepted any ``.write`` + survived every other pin, because the sibling clause was tested + somewhere else and this one had no negative control of its own.""" + source = "def f(handle):\n try:\n handle.write('note')\n except OSError:\n pass\n" + assert self._passed(tmp_path, source) is False + + def test_a_block_of_pure_bookkeeping_is_not_a_diagnostic(self, tmp_path): + """Negative control: bookkeeping may sit BESIDE a report, never stand in + for one. Without the at-least-one-report clause, every `seen.add(x)` + wrapped in a try would buy the exemption.""" + source = "def f(seen, x):\n try:\n seen.add(x)\n except OSError:\n pass\n" + assert self._passed(tmp_path, source) is False + + +class TestAHandlerThatHandsTheExceptionOnIsNotSilent: + """@daemon's second shape, one level out from @spawn's classify-and-return: + + except OSError as exc: + _record_unresolved(path, exc) + return path + + Nothing is dropped — the exception becomes an argument to a named function + that owns reporting it. There the information became a return value, here it + becomes an argument; in both, the caller decides. + + Measured before landing: 10 handlers match fleet-wide and all 10 were read + individually — six are this cure delegating to a named reporter, the rest a + Rich report before sys.exit (@cli), a subprocess writing its error as JSON + (@memory), a deliberately rate-limited queue warning (@prax) and a retained + list (@trigger). Zero looked like a swallow. + """ + + def _passed(self, tmp_path, source): + from aipass.seedgo.apps.handlers.aipass_standards import silent_catch_check + + target = tmp_path / "probe.py" + target.write_text(source, encoding="utf-8") + return silent_catch_check.check_module(str(target))["checks"][0]["passed"] + + def test_passing_the_exception_to_a_named_reporter_is_not_silent(self, tmp_path): + source = ( + "def f(path, record):\n try:\n return path.resolve()\n" + " except OSError as exc:\n record(path, exc)\n return path\n" + ) + assert self._passed(tmp_path, source) is True + + def test_retaining_the_exception_in_a_list_is_not_silent(self, tmp_path): + source = "def f(kept):\n try:\n go()\n except OSError as exc:\n kept.append(str(exc))\n" + assert self._passed(tmp_path, source) is True + + def test_binding_the_exception_and_ignoring_it_keeps_its_finding(self, tmp_path): + """The clause is that the object LEAVES. Binding `as exc` and dropping it + is the defect wearing the exemption's syntax. + + The body calls something unrelated on purpose. ``return None`` would + have been the obvious probe and it is cleared by a DIFFERENT, older + clause — a named exception returning a constant is @spawn's + classify-and-return, ratified before this one — so it would have proved + nothing about the clause under test. Found by this pin failing. + """ + source = "def f(cleanup):\n try:\n go()\n except OSError as exc:\n cleanup()\n" + assert self._passed(tmp_path, source) is False + + def test_an_unbound_handler_cannot_borrow_the_clause(self, tmp_path): + """No `as` means no exception object to hand on, however many calls the + body makes. Without this, `except OSError: cleanup(path)` would pass.""" + source = "def f(path, cleanup):\n try:\n go()\n except OSError:\n cleanup(path)\n" + assert self._passed(tmp_path, source) is False + + +class TestErrorHandlingSharesTheDiagnosticGuardClause: + """Two checkers, one question, one implementation — @canary's point that a + single discriminator clears both findings. Shared rather than copied so the + two cannot drift into disagreeing about the same handler. + """ + + def _passed(self, tmp_path, source): + from aipass.seedgo.apps.handlers.aipass_standards import error_handling_check + + target = tmp_path / "probe.py" + target.write_text(source, encoding="utf-8") + return error_handling_check.check_module(str(target))["checks"][0]["passed"] + + def test_a_pass_guarding_a_report_is_not_a_silent_failure(self, tmp_path): + source = "def f(logger, exc):\n try:\n logger.debug('x', exc)\n except Exception:\n pass\n" + assert self._passed(tmp_path, source) is True + + def test_a_pass_over_real_work_is_still_a_silent_failure(self, tmp_path): + source = "def f(path):\n try:\n path.unlink()\n except OSError:\n pass\n" + assert self._passed(tmp_path, source) is False + + +class TestTheBootstrapChainSeesRelativeImports: + """@canary's json_structure finding, and it was a hole in an exemption I + ratified the night before. + + Clause 2 of the pre-logging bootstrap exemption asks whether the logging + substrate's own imports REACH a module. The walk only followed absolute + ``aipass.*`` imports, and @canary's json_handler reaches its stdlib-only + helper with ``from ..paths import module_file`` — so a module that IS + beneath the logging system was invisible to the walk and got told to import + it. A relative import is a STATIC fact, unlike the importlib hops the walk + deliberately cannot see, so resolving it moves the set toward correct in the + direction the walk's own docstring already asks for. + """ + + def _resolve(self, module, level, package): + from aipass.seedgo.apps.handlers.aipass_standards import json_structure_check + + return json_structure_check._resolve_relative(module, level, package) + + def test_a_sibling_import_resolves(self): + assert self._resolve("paths", 2, "aipass.canary.apps.handlers.json") == "aipass.canary.apps.handlers.paths" + + def test_a_same_package_import_resolves(self): + assert self._resolve("utils", 1, "aipass.canary.apps.handlers") == "aipass.canary.apps.handlers.utils" + + def test_a_bare_from_dot_import_resolves_to_the_package(self): + assert self._resolve(None, 1, "aipass.canary.apps.handlers") == "aipass.canary.apps.handlers" + + def test_no_package_means_no_resolution(self): + """Negative control: guessing an absolute name for a file we cannot + place would put fabricated members into the chain, and the chain hands + out exemptions. Coming up SHORT is the safe direction.""" + assert self._resolve("paths", 2, None) is None + + def test_climbing_past_the_root_answers_None(self): + assert self._resolve("x", 9, "aipass.canary") is None + + def test_an_absolute_import_is_untouched_by_the_resolver(self): + assert self._resolve("aipass.prax", 0, "aipass.canary.apps") is None + + def test_the_walk_reaches_a_relatively_imported_bootstrap_module(self, tmp_path): + """End to end on the real tree: @canary's helper is in the chain now.""" + from pathlib import Path + + from aipass.seedgo.apps.handlers.aipass_standards import json_structure_check + + # parents[2] is src/aipass — parents[3] is src, and the wrong one made + # this skip instead of run. A skip reports its own defeat as a pass, + # which is the whole reason the branch is asserted rather than guessed. + aipass_root = Path(__file__).resolve().parents[2] + assert aipass_root.name == "aipass", aipass_root + target = aipass_root / "canary" / "apps" / "handlers" / "paths.py" + if not target.exists(): + import pytest + + pytest.skip(f"canary's paths.py is not on this machine ({target})") + assert json_structure_check._is_prelogging_bootstrap(target, target.read_text(encoding="utf-8")) is True + + +class TestEncapsulationDerivesItsInfrastructureAllowList: + """@daemon, 2026-08-31: two adjacent lines in their entry point, the + json_handler import passing and the module_root import scoring the file 66%. + + Both are same-branch handler imports by the branch's own entry point, and + the handlers guard itself permits exactly this — it blocks CROSS-branch + imports. The checker exempted json_handler by NAME rather than by the + property that makes it fine. + + The property: if json_handler may be imported anywhere, so may anything + json_handler itself imports, because that module is BENEATH json_handler in + the branch's own import order. Measured 2026-08-31: 8 of 9 branches carrying + the dead-cwd cure have their json_handler importing it, so the derived set + finds it without anyone naming a file. + """ + + def _names(self, branch_root): + from aipass.seedgo.apps.handlers.aipass_standards import encapsulation_check + + encapsulation_check._infrastructure_handlers.cache_clear() + return encapsulation_check._infrastructure_handlers(str(branch_root)) + + def test_a_handler_the_branchs_json_handler_imports_is_infrastructure(self, tmp_path): + branch = tmp_path / "mybranch" + handlers = branch / "apps" / "handlers" + (handlers / "json").mkdir(parents=True) + (handlers / "module_root.py").write_text("def module_file(f):\n return f\n", encoding="utf-8") + (handlers / "json" / "json_handler.py").write_text( + "from aipass.mybranch.apps.handlers.module_root import module_file\n", encoding="utf-8" + ) + assert "module_root" in self._names(branch) + + def test_a_relatively_imported_one_counts_the_same(self, tmp_path): + """@canary spells theirs ``from ..paths import module_file``. A rule that + saw only absolute imports would exempt eight branches and not the ninth.""" + branch = tmp_path / "mybranch" + handlers = branch / "apps" / "handlers" + (handlers / "json").mkdir(parents=True) + (handlers / "paths.py").write_text("def module_file(f):\n return f\n", encoding="utf-8") + (handlers / "json" / "json_handler.py").write_text("from ..paths import module_file\n", encoding="utf-8") + assert "paths" in self._names(branch) + + def test_a_handler_json_handler_does_not_import_is_not_infrastructure(self, tmp_path): + """Negative control: the set is DERIVED, not a longer allow-list. A + domain handler stays behind its module entry point.""" + branch = tmp_path / "mybranch" + handlers = branch / "apps" / "handlers" + (handlers / "json").mkdir(parents=True) + (handlers / "openrouter.py").write_text("def get_response():\n return None\n", encoding="utf-8") + (handlers / "json" / "json_handler.py").write_text("import json\n", encoding="utf-8") + assert "openrouter" not in self._names(branch) + + def test_a_name_that_is_not_a_file_is_not_admitted(self, tmp_path): + """The imported NAME must resolve to a real handler module. Without that, + `from ..paths import module_file` would admit `module_file` too — a + function name standing in for a module.""" + branch = tmp_path / "mybranch" + handlers = branch / "apps" / "handlers" + (handlers / "json").mkdir(parents=True) + (handlers / "paths.py").write_text("def module_file(f):\n return f\n", encoding="utf-8") + (handlers / "json" / "json_handler.py").write_text("from ..paths import module_file\n", encoding="utf-8") + assert "module_file" not in self._names(branch) + + def test_an_unreadable_json_handler_grants_nothing(self, tmp_path): + """Ignorance is not evidence: a branch whose json_handler cannot be + parsed keeps the ordinary rule rather than being exempted by the + failure.""" + branch = tmp_path / "mybranch" + handlers = branch / "apps" / "handlers" + (handlers / "json").mkdir(parents=True) + (handlers / "json" / "json_handler.py").write_text("def broken(:\n", encoding="utf-8") + assert self._names(branch) == frozenset() + + +# --------------------------------------------------------------------------- +# cli: handler separation reads the STREAM, not the spelling +# --------------------------------------------------------------------------- + + +class TestHandlerSeparationJudgesTheStreamNotTheSpelling: + """@flow, 2026-08-31: the cross-branch import fence scored 0 for two + ``print(..., file=sys.stderr)`` lines behind an ``AIPASS_DEBUG_GUARD`` + env var, with the message "use logger instead" — in the one file in the + branch that runs before any logger exists. + + Their argument was about the bootstrap window. The measurement found + something plainer and worse: the checker ALREADY PASSES the identical + effect written the other way. ``sys.stderr.write(msg)`` in a handler scores + 100 (@skills' module_paths.py, measured); ``print(msg, file=sys.stderr)`` + scores 0. Same stream, same bytes, same window — 100 points of difference + decided by which spelling the author reached for. + + Handler separation exists so a handler does not DISPLAY. Display is stdout, + the channel a router's output travels on; stderr is where a diagnostic goes + precisely so it does not pollute that channel. So the fix is not a new + exemption — it is the rule finally saying what it already meant on one of + its two spellings. + + MEASURED before landing: 351 print lines across the fleet's handlers, 12 of + them stderr-directed. Exactly six files change verdict, and all six are the + same fence block (@ai_mail, @cli, @flow, @memory, @prax, @trigger). + + The other two stderr-directed prints live in @ai_mail's wake.py and + @skills' telegram notifier, and BOTH files scored 100 before this clause + and after it — every print in them, stderr-directed or not, sits inside an + ``if __name__ == "__main__":`` block that handler separation has always + excluded. Corrected here because the first measurement called them + "partial" from the raw regex without asking the checker: a proxy count is + not a verdict count, which is the mistake this class exists to stop me + repeating. + """ + + def _sep(self, content): + from aipass.seedgo.apps.handlers.aipass_standards.cli_check import check_handler_separation + + return check_handler_separation(content) + + def test_a_stderr_directed_print_is_a_diagnostic_not_display(self): + content = ( + "import os\n" + "import sys\n" + "\n" + "def _guard(caller_file, import_line):\n" + " if os.environ.get('AIPASS_DEBUG_GUARD'):\n" + " print(f'[GUARD DEBUG] caller_file = {caller_file}', file=sys.stderr)\n" + " print(f'[GUARD DEBUG] import_line = {import_line}', file=sys.stderr)\n" + ) + assert self._sep(content)["passed"] is True + + def test_a_bare_print_keeps_its_finding(self): + """Negative control. Without this the clause would read as "prints are + fine in handlers now", which is the opposite of the ruling.""" + result = self._sep("def show(x):\n print(x)\n") + assert result["passed"] is False + assert "[2]" in result["message"] + + def test_an_explicitly_stdout_directed_print_keeps_its_finding(self): + """The discriminator is the STREAM. Naming stdout out loud is still + display — arguably more so, since the author chose it.""" + assert self._sep("import sys\n\ndef show(x):\n print(x, file=sys.stdout)\n")["passed"] is False + + def test_a_print_to_some_other_file_handle_keeps_its_finding(self): + """A write to a handle nobody is watching is not a diagnostic. Same + clause @commons' silent_catch ruling needed this morning: ``.write`` + reports only when the thing written to is a standard stream.""" + assert self._sep("def dump(x, fh):\n print(x, file=fh)\n")["passed"] is False + + def test_only_the_file_keyword_directs_a_stream(self): + """Mutation control (M3, survived the first run). Loosening the keyword + check to accept ``sep`` exempted ``print(x, sep=sys.stderr)`` — a call + that still writes every byte to stdout. Contrived code, but the clause + must key on the argument that actually chooses the stream.""" + assert self._sep("import sys\n\ndef show(x):\n print(x, sep=sys.stderr)\n")["passed"] is False + + def test_a_file_holding_both_keeps_the_finding_and_names_only_the_display_lines(self): + """The exempted line must not shift the reported line numbers, or the + finding sends its owner to the wrong place. Synthetic rather than + borrowed from the fleet: the two live files that hold both kinds put + every print inside a ``__main__`` block, so neither would exercise + this.""" + content = ( + "import sys\n" + "\n" + "def report(x):\n" + " print('to the user', x)\n" + " print('diagnostic', file=sys.stderr)\n" + " print('also to the user', x)\n" + ) + result = self._sep(content) + assert result["passed"] is False + assert "[4, 6]" in result["message"] + + def test_an_unparseable_file_keeps_every_print_finding(self): + """Ignorance is not evidence. A file whose AST cannot be built has no + stderr-directed prints PROVEN, so none are exempted — the same + direction json_structure's bootstrap clause errs in.""" + result = self._sep("import sys\ndef broken(:\n print('x', file=sys.stderr)\n") + assert result["passed"] is False + + def test_the_two_real_fleet_files_now_agree(self): + """End-to-end against the live tree, because the whole finding was that + two spellings of one effect disagreed by 100 points. Asserts the files + exist rather than skipping: a vacuous skip here would report the + agreement it never checked.""" + from pathlib import Path + + from aipass.seedgo.apps.handlers.aipass_standards import cli_check + + aipass_root = Path(__file__).resolve().parents[2] + assert aipass_root.name == "aipass", aipass_root + stream_write = aipass_root / "skills" / "apps" / "handlers" / "module_paths.py" + stream_print = aipass_root / "flow" / "apps" / "handlers" / "__init__.py" + assert stream_write.exists() and stream_print.exists() + + written = cli_check.check_module(str(stream_write)) + printed = cli_check.check_module(str(stream_print)) + assert written["score"] == 100 + assert printed["score"] == written["score"], [c["message"] for c in printed["checks"] if not c["passed"]] + + +class TestTheCliRulingsAreQueryableNotJustEnforced: + """@commons, 2026-08-31: "it currently lives in a docstring nobody queries, + and I only found the wall by hitting it. A branch reading the standard first + would have saved this whole exchange." + + A checker that enforces a rule the published standard does not state makes + every branch discover it by failing. These pins hold the two cli rulings in + the queryable content, and each asserts the CONCRETE spelling a reader needs + rather than a topic word, so trimming the example out fails here. + """ + + def _content(self): + from aipass.seedgo.apps.handlers.aipass_standards.cli_content import get_cli_standards + + return get_cli_standards() + + def test_the_pre_console_window_is_published_with_its_boundary(self): + content = self._content() + assert "pre-console bootstrap window" in content + assert "STRICTLY prior" in content + + def test_the_stream_ruling_is_published_with_both_spellings(self): + content = self._content() + assert "print(msg, file=sys.stderr)" in content + assert "sys.stderr.write(msg)" in content + assert "print(msg, file=sys.stdout)" in content + + def test_the_published_rule_and_the_checker_agree(self): + """The point of publishing is that a branch reading it gets the same + answer the audit gives. Runs the checker over the exact snippets the + standard shows, so the two cannot drift apart silently.""" + from aipass.seedgo.apps.handlers.aipass_standards.cli_check import check_handler_separation + + allowed = "import sys\n\ndef f(msg):\n print(msg, file=sys.stderr)\n" + refused = "import sys\n\ndef f(msg):\n print(msg, file=sys.stdout)\n" + assert check_handler_separation(allowed)["passed"] is True + assert check_handler_separation(refused)["passed"] is False + + +class TestTheEmptyAnswerIsAConstantWhateverItsType: + """Caught by dogfooding: my own new cli_check helper scored 0 on + silent_catch for ``except SyntaxError: return set()``. + + The classify-and-return clause reads ``ast.Constant``, which in Python + covers scalars and nothing else. So ``return ""`` was allowed and + ``return []`` was flagged — two spellings of one idea, "this function found + nothing", separated by which type the function happens to return. A + function whose contract is a list has no other way to say it, and + ``set()`` and ``frozenset()`` have no literal spelling at all. + + Same species as the stream ruling three hours earlier in this file: the + verdict was being decided by the spelling rather than by what the code + does. EMPTY only — a handler returning ``[1, 2]`` is fabricating an answer, + not reporting an absence, and keeps its finding. + + MEASURED before landing: 16 handlers across the fleet match, every one of + them ``return []``, ``return {}`` or ``return set()``. + """ + + def _judge(self, body): + import ast + + from aipass.seedgo.apps.handlers.aipass_standards import silent_catch_check + + source = f"def f(c):\n try:\n return parse(c)\n except SyntaxError:\n {body}\n" + tree = ast.parse(source) + flagged = [] + for node in ast.walk(tree): + if isinstance(node, ast.Try): + for handler in node.handlers: + flagged += silent_catch_check._judge_handler(handler, node, "x.py") + return bool(flagged) + + @pytest.mark.parametrize("body", ["return []", "return {}", "return ()", "return set()", "return frozenset()"]) + def test_an_empty_container_is_the_same_answer_as_an_empty_string(self, body): + assert self._judge(body) is False, body + + def test_the_scalar_spellings_still_pass(self): + """Control: the clause this widens must keep working.""" + assert self._judge('return ""') is False + assert self._judge("return 0") is False + + @pytest.mark.parametrize("body", ["return [1, 2]", "return {'a': 1}", "return {'x'}"]) + def test_a_NON_empty_literal_keeps_its_finding(self, body): + """The boundary, and the reason the clause is not just "any literal": a + handler that invents content is not reporting an absence.""" + assert self._judge(body) is True, body + + @pytest.mark.parametrize("body", ["return list(c)", "return dict(a=1)", "return set(c)"]) + def test_a_builtin_called_WITH_arguments_keeps_its_finding(self, body): + """Negative control for the call form. ``set()`` is the empty set; + ``set(c)`` is a computed answer wearing the same name.""" + assert self._judge(body) is True, body + + def test_a_computed_return_still_keeps_its_finding(self): + assert self._judge("return compute(c)") is True + + @pytest.mark.parametrize("body", ["return compute()", "return build_default()", "return Path()"]) + def test_a_zero_ARGUMENT_call_to_anything_else_keeps_its_finding(self, body): + """Mutation control (M4, survived the first run). Dropping the builtin + name check exempted every no-argument call — a computed fallback that + happens to take no arguments is still computed, and ``Path()`` is not an + absence. The clause is the NAME and the emptiness together.""" + assert self._judge(body) is True, body + + def test_a_broad_except_gains_nothing_from_this(self): + """The specific-exception clause is what makes the returned value + meaningful. ``except Exception`` learns the caller nothing, so an empty + list from it is still a swallow.""" + import ast + + from aipass.seedgo.apps.handlers.aipass_standards import silent_catch_check + + source = "def f(c):\n try:\n return parse(c)\n except Exception:\n return []\n" + tree = ast.parse(source) + flagged = [] + for node in ast.walk(tree): + if isinstance(node, ast.Try): + for handler in node.handlers: + flagged += silent_catch_check._judge_handler(handler, node, "x.py") + assert flagged + + def test_my_own_helper_is_the_case_that_found_this(self): + """End-to-end on the live file, because dogfooding is what surfaced it + and a synthetic-only pin would not have.""" + from pathlib import Path + + from aipass.seedgo.apps.handlers.aipass_standards import silent_catch_check + + target = Path(__file__).resolve().parents[1] / "apps" / "handlers" / "aipass_standards" / "cli_check.py" + assert target.exists(), target + assert "_stderr_directed_print_lines" in target.read_text(encoding="utf-8") + assert silent_catch_check.check_module(str(target))["score"] == 100 + + def test_the_empty_answer_ruling_is_published_too(self): + """@commons' point, applied to my own new clause the same day: a rule + that lives only in the checker makes every branch discover it by + failing.""" + from aipass.seedgo.apps.handlers.aipass_standards.silent_catch_content import get_silent_catch_standards + + content = get_silent_catch_standards() + assert "EMPTY ANSWER" in content + assert "return set()" in content + assert "return set(c)" in content + + +class TestFunctionDocstringsReadTheSourceNotTheStrings: + """Caught by dogfooding: my own new nominator scored 50 on documentation + for ``test_every_found_item_is_valid`` — a function that does not exist. It + is three lines of a CODE EXAMPLE inside the module docstring. + + ``check_function_docstrings`` was a line scan: ``stripped.startswith("def ")`` + over the raw file, so any ``def`` inside a string literal was read as a real + function. Every branch that built a subprocess world this week embedded + Python in a string and got flagged for it — @api, @canary, @skills and two + of my own files. + + The same scan had the mirror defect: it never matched ``async def`` at all, + so an undocumented async function was invisible. ``find_import_section_end`` + in the same pack has handled ``async def`` since it was written. + + AST reads what Python reads. MEASURED before landing: 1841 files, 7 verdicts + change — 5 false positives cleared, 2 genuine misses found, both of them + async. Scope is unchanged on purpose: nested functions still count, because + narrowing that would be a 40-file amnesty and a different decision. + """ + + def _check(self, source): + from aipass.seedgo.apps.handlers.aipass_standards.documentation_check import check_function_docstrings + + return check_function_docstrings(source, source.split("\n")) + + def test_a_def_inside_a_docstring_is_not_a_function(self): + """The exact shape that flagged my own nominator: a code example whose + next `def`-or-`class` sentinel arrives before the docstring closes.""" + source = ( + '"""Module.\n' + "\n" + ' @pytest.mark.parametrize("item", collect())\n' + " def test_every_found_item_is_valid(item):\n" + ' assert item["ok"]\n' + '"""\n' + "\n" + "def real_one(x):\n" + ' """Documented."""\n' + " return x\n" + ) + # The fixture must PARSE, or this exercises the SyntaxError fallback and + # proves nothing about the AST path. Caught while writing it: the first + # version left the module docstring unterminated and went red for that. + import ast + + ast.parse(source) + result = self._check(source) + assert result["passed"] is True, result["message"] + + def test_a_def_inside_a_string_constant_is_not_a_function(self): + """The dead-cwd worlds' shape: a whole module built as a string and fed + to a child. @canary, @api and @skills all carry one.""" + source = ( + 'WORLD = """\nimport os\ndef realpath(p, *a, **k):\n return p\n\ndef helper(q):\n return q\n"""\n' + ) + result = self._check(source) + assert result["passed"] is True, result["message"] + + def test_a_real_undocumented_function_still_fails(self): + """Negative control. Without this the fix reads as "documentation is + optional now".""" + result = self._check("def visible(x):\n return x\n") + assert result["passed"] is False + assert "visible" in result["message"] + + def test_an_undocumented_ASYNC_function_is_finally_seen(self): + """The line scan matched 'def ' and never 'async def', so @skills' + telethon_auth.main has been invisible for as long as it has existed.""" + result = self._check("async def main() -> None:\n return None\n") + assert result["passed"] is False + assert "main" in result["message"] + + def test_a_documented_async_function_passes(self): + assert self._check('async def main() -> None:\n """Does a thing."""\n')["passed"] is True + + def test_a_private_function_is_still_exempt(self): + assert self._check("def _hidden(x):\n return x\n")["passed"] is True + + def test_an_unparseable_file_keeps_the_line_scans_answer(self): + """Ignorance is not evidence. A file whose AST cannot be built has not + been PROVEN to document everything, so it falls back rather than being + exempted by the failure — the direction json_structure's bootstrap + clause errs in.""" + result = self._check("def visible(:\n return 1\n") + assert result["passed"] is False + + def test_the_reported_line_is_the_def_not_an_index(self): + """@memory was sent to line 40 for a violation at line 106 once. The + line in the message must be the line in the file.""" + source = "\n\n\n\n\ndef visible(x):\n return x\n" + assert "line 6" in self._check(source)["message"] + + +class TestModuleDocstringReadsThePositionNotTheSpelling: + """The sibling defect, found the same way: by auditing a file of my own. + + ``check_module_docstring`` was a line scan for a line STARTING with a triple + quote in the first 30. That is wrong in both directions, and both were + measured across 1,845 files before the rule changed: + + - an r-prefixed module docstring does not start with a quote character, so + the scan reported MISSING on a file that has one (this checker's own + ``posix_literal_check.py``, scored 50 for a docstring it carries); + - any triple-quoted string in the first 30 lines was credited to the + module - a class docstring, a function docstring on a short file, a + string sitting after an import where Python discards it. Seven files + fleet-wide, and that is the direction that matters: a false negative + gets believed. + + Blast radius measured with the real checker before the change: 8 verdicts + move across 1,560 files. Six from 100 to 50 (@skills 5, @ai_mail 1), one + from 50 to 0 (@hooks), one from 50 to 100 (mine). Owners mailed - reported, + never edited. + """ + + def test_an_r_prefixed_docstring_is_a_docstring(self): + """The false negative that started it.""" + source = 'r"""Raw module docstring."""\n\n\ndef f():\n pass\n' + result = documentation_check.check_module_docstring(source.split("\n")) + assert result["passed"] is True + + @pytest.mark.parametrize("prefix", ["r", "R", "u", "b", "rb", "f"]) + def test_every_string_prefix_python_accepts_is_accepted_here(self, prefix): + """A literal table over the prefixes, so this one cannot vanish. b and + rb are not docstrings to ``ast.get_docstring`` and f-strings are not + constants at all - the point of the row is that the ANSWER is Python's, + whatever it is, rather than a guess made by looking at the first + character.""" + source = prefix + '"""Text."""\n' + expected = ast.get_docstring(ast.parse(source)) is not None + result = documentation_check.check_module_docstring(source.split("\n")) + assert result["passed"] is expected + + def test_a_string_after_an_import_is_NOT_a_module_docstring(self): + """@ai_mail's header.py, reduced. Python evaluates and discards it; + ``help()`` shows nothing. A reader sees documentation, which is exactly + why the scan agreed with the reader and not with the interpreter.""" + source = 'import os\n\n"""Looks like documentation."""\n' + result = documentation_check.check_module_docstring(source.split("\n")) + assert result["passed"] is False + + def test_a_function_docstring_is_not_credited_to_the_module(self): + """@skills' registry.py, reduced - five files of theirs scored 100 on + the strength of the first function's docstring.""" + source = 'from pathlib import Path\n\n\ndef f():\n """Real, and not the module\'s."""\n' + result = documentation_check.check_module_docstring(source.split("\n")) + assert result["passed"] is False + + def test_a_class_docstring_is_not_credited_to_the_module(self): + """@hooks' test_live_config_timeouts.py, reduced.""" + source = 'import json\n\n\nclass TestX:\n """A class docstring."""\n' + result = documentation_check.check_module_docstring(source.split("\n")) + assert result["passed"] is False + + def test_a_real_module_docstring_still_passes(self): + """The positive control. A rule that failed everything would satisfy + every negative pin above and teach nothing.""" + source = '"""The module docstring."""\n\n\ndef f():\n pass\n' + result = documentation_check.check_module_docstring(source.split("\n")) + assert result["passed"] is True + + def test_a_docstring_after_the_AIPass_header_still_passes(self): + """Every file in the fleet opens with a comment banner. If the AST arm + had been wrong about comments, 1,677 files would have gone red at once - + which is the kind of blast radius worth pinning rather than assuming.""" + source = "# === AIPass ===\n# Name: x.py\n# ===\n\n" + '"""Doc."""\n' + result = documentation_check.check_module_docstring(source.split("\n")) + assert result["passed"] is True + + def test_an_unparseable_file_falls_back_and_SAYS_SO(self): + """The fallback must not read as a verdict from the real arm. My own + round-2 sentence: an exemption bought with a SyntaxError is an exemption + granted on ignorance.""" + source = '"""Doc."""\n\ndef broken(\n' + with pytest.raises(SyntaxError): + ast.parse(source) + result = documentation_check.check_module_docstring(source.split("\n")) + assert result["passed"] is True + assert "does not parse" in result["message"] + + def test_the_unparseable_fallback_can_still_FAIL(self): + """The negative control for the control. A fallback that always passes + would make every unparseable file clean, which is the exemption-on- + ignorance shape one level down.""" + source = "import os\n\ndef broken(\n" + result = documentation_check.check_module_docstring(source.split("\n")) + assert result["passed"] is False + assert "does not parse" in result["message"] diff --git a/src/aipass/seedgo/tests/test_checkers_batch8.py b/src/aipass/seedgo/tests/test_checkers_batch8.py index 31c387d11..183fa3d36 100644 --- a/src/aipass/seedgo/tests/test_checkers_batch8.py +++ b/src/aipass/seedgo/tests/test_checkers_batch8.py @@ -327,7 +327,12 @@ def test_auto_detection_missing(): result = check_auto_detection(content) assert result is not None assert result["passed"] is False - assert "missing auto-detection" in result["message"] + assert "never reads the caller frame" in result["message"] + # The message used to PRESCRIBE inspect.stack(), which is the Windows + # dead-cwd defect the fleet is removing (@prax, 2026-08-31). Pinned as a + # refusal so nobody restores the old wording as a "clearer" instruction. + assert "use sys._getframe" in result["message"] + assert "NOT inspect.stack()" in result["message"] def test_auto_detection_with_get_caller(): @@ -1290,6 +1295,70 @@ def test_constant_naming_function_call_ignored(): assert result is None +def test_constant_naming_exempts_the_pytest_contract_globals(): + """The names pytest reads by their exact spelling are not style. + + @devpulse hit this live: the auto-fix lane offered to rename + collect_ignore_glob in src/aipass/conftest.py, and they refused it, because + pytest looks that name up at collection time and an UPPER_CASE version + would disable the ignore mechanism while reporting a tidier file. Measured + across the fleet before the exemption shipped: 7 verdicts change, every one + of them a conftest going from FAIL to no-verdict, and no file moves from + pass to fail. + """ + from aipass.seedgo.apps.handlers.aipass_standards.naming_check import ( + PYTEST_CONTRACT_GLOBALS, + check_constant_naming, + ) + + for name in sorted(PYTEST_CONTRACT_GLOBALS): + content = f'{name} = ["x"]\n' + assert check_constant_naming(content) is None, ( + f"{name} is read by pytest under that spelling and must not be nominated" + ) + + +def test_the_exemption_is_a_NAMED_SET_and_not_a_lowercase_amnesty(): + """The control that keeps the cure narrow. + + An exemption that widened to "lowercase module globals in files that look + like conftests" would acquit the species this rule exists for. So a + lowercase global that is NOT in the set still fails, and it fails in the + same file as an exempt one - which is the arrangement a real conftest has. + """ + from aipass.seedgo.apps.handlers.aipass_standards.naming_check import ( + check_constant_naming, + ) + + result = check_constant_naming('collect_ignore_glob = ["x"]\nmy_helper = 3\n') + assert result is not None, "the non-contract name was swallowed with the exempt one" + assert result["passed"] is False + assert "my_helper" in result["message"] + assert "collect_ignore_glob" not in result["message"] + + +def test_the_real_conftest_that_reported_it_is_no_longer_nominated(): + """Reproduced against the actual file, not a synthetic string. + + The report named src/aipass/conftest.py. This reads that file if it is + there and requires the verdict to be clean; if it has moved, the row says + so rather than passing quietly on a file it never opened. + """ + from pathlib import Path + + from aipass.seedgo.apps.handlers.aipass_standards.naming_check import ( + check_constant_naming, + ) + + conftest = Path(__file__).resolve().parents[2] / "conftest.py" + if not conftest.is_file(): + pytest.skip(f"the reported file is not at {conftest} on this checkout") + result = check_constant_naming(conftest.read_text(encoding="utf-8")) + assert result is None or result["passed"] is True, ( + f"the file that produced the counterexample still fails: {result}" + ) + + # =========================================================================== # 15. naming_check -- check_class_naming # =========================================================================== diff --git a/src/aipass/seedgo/tests/test_coverage_audit.py b/src/aipass/seedgo/tests/test_coverage_audit.py index 77728434d..ca8838893 100644 --- a/src/aipass/seedgo/tests/test_coverage_audit.py +++ b/src/aipass/seedgo/tests/test_coverage_audit.py @@ -746,6 +746,47 @@ def test_basic_summary(self): result = self._make_audit_result() print_branch_summary(result) + def test_the_header_states_what_it_measured_not_only_how_much(self, monkeypatch): + """The count is scoped out loud, because the corpus is not the branch. + + ``_collect_py_files`` walks ``apps/**/*.py`` and nothing else, so a bare + "N files checked" beside a 100 invites the reader to conclude the branch + is clean when a third of its Python was never opened. This asserts the + SCOPE WORDS, not the number: a header that keeps the count and drops the + qualifier is exactly the overclaim, and it would pass a count-only pin. + """ + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + from aipass.seedgo.apps.handlers.audit import audit_display + + mock_con = MagicMock() + monkeypatch.setattr(audit_display, "console", mock_con) + print_branch_summary(self._make_audit_result(files_checked=171)) + rendered = " ".join(str(c) for c in mock_con.print.call_args_list) + assert "171 production files measured" in rendered + assert "apps/ only" in rendered + assert "tests/ not in the corpus" in rendered + + def test_the_scope_words_are_not_hardcoded_around_the_count(self, monkeypatch): + """Negative control on the pin above: the number still has to be real. + + A header that printed the qualifier with a constant would satisfy every + assertion above while reporting the wrong corpus size. + """ + from aipass.seedgo.apps.handlers.audit.audit_display import ( + print_branch_summary, + ) + + from aipass.seedgo.apps.handlers.audit import audit_display + + mock_con = MagicMock() + monkeypatch.setattr(audit_display, "console", mock_con) + print_branch_summary(self._make_audit_result(files_checked=3)) + rendered = " ".join(str(c) for c in mock_con.print.call_args_list) + assert "3 production files measured" in rendered + def test_post_check_crash_prints_even_at_a_perfect_score(self): """A crashed post-check reaches the CONSOLE on a branch scoring 100. diff --git a/src/aipass/seedgo/tests/test_empty_parametrize_nominator.py b/src/aipass/seedgo/tests/test_empty_parametrize_nominator.py new file mode 100644 index 000000000..fd51e6457 --- /dev/null +++ b/src/aipass/seedgo/tests/test_empty_parametrize_nominator.py @@ -0,0 +1,302 @@ +# =================== META ==================== +# Name: test_empty_parametrize_nominator.py +# Description: Pins the VANISHING-TABLE nominator against a table that can silently empty +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""A parametrized test over an empty collection reports as passing. + +@drone found it building the content-anchored bypass rule (2026-08-31): their +first ``test_bypass_anchors.py`` SURVIVED a mutant that blinded the collector to +``return []``, because the anchor checks were parametrized over the collector's +output and pytest reports a parametrized test with no cases as SKIPPED. The file +printed "1 passed, 2 skipped" and exit code 0 for an instrument that had checked +nothing. + +Reproduced independently here before the rule was written — see +``test_the_hazard_is_real_on_this_pytest`` — because a rule built on a reported +behaviour that this pytest does not actually have would nominate a defect nobody +can hit. +""" + +import ast +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from aipass.seedgo.apps.handlers.tests_pytest_standards import corpus, empty_parametrize_check + + +def _nominate(source: str, tmp_path: Path): + """Run the nominator over one synthetic test module.""" + target = tmp_path / "tests" + target.mkdir(exist_ok=True) + (target / "test_synthetic.py").write_text(textwrap.dedent(source), encoding="utf-8") + return empty_parametrize_check.nominate(corpus.build(tmp_path, ("tests",))) + + +class TestTheHazardItself: + def test_the_hazard_is_real_on_this_pytest(self, tmp_path): + """The measurement the rule stands on, not a citation of it. + + A parametrized test over an empty collection must report SKIPPED with + an exit code of 0 — green — on the pytest actually installed here. If a + future pytest made this an error the rule would be nominating a defect + that can no longer happen, and this goes red rather than the rule + quietly outliving its reason. + """ + probe = tmp_path / "test_probe.py" + probe.write_text( + textwrap.dedent( + """ + import pytest + + def collect(): + return [] + + @pytest.mark.parametrize("item", collect()) + def test_every_found_item_is_valid(item): + assert item["ok"] + """ + ), + encoding="utf-8", + ) + result = subprocess.run( + [sys.executable, "-m", "pytest", str(probe), "-q", "-p", "no:randomly"], + capture_output=True, + text=True, + cwd=str(tmp_path), + ) + assert result.returncode == 0, result.stdout + assert "skipped" in result.stdout, result.stdout + assert "failed" not in result.stdout, result.stdout + + +class TestWhatItNominates: + def test_a_table_drawn_from_a_call_is_nominated(self, tmp_path): + rows = _nominate( + """ + import pytest + + def collect(): + return [] + + @pytest.mark.parametrize("item", collect()) + def test_each(item): + assert item + """, + tmp_path, + ) + assert [r["species"] for r in rows] == ["VANISHING-TABLE"] + assert "collect()" in rows[0]["evidence"]["argvalues"] + + def test_the_row_carries_the_decorator_line_not_the_functions(self, tmp_path): + """A nomination that points at the wrong line sends its owner to the + wrong place, which is the whole complaint behind @memory's line-40-for- + line-106 finding.""" + source = """ + import pytest + + def collect(): + return [] + + @pytest.mark.parametrize("item", collect()) + def test_each(item): + assert item + """ + rows = _nominate(source, tmp_path) + # Asserted against the FILE the nominator read, not against a number + # counted by hand — a hardcoded line is one dedent away from testing the + # fixture instead of the rule. + written = (tmp_path / "tests" / "test_synthetic.py").read_text(encoding="utf-8").split("\n") + assert "parametrize" in written[rows[0]["line"] - 1], written[rows[0]["line"] - 1] + + +class TestWhatItAcquits: + @pytest.mark.parametrize( + "argvalues", + ["[1, 2, 3]", "(1, 2)", '{"a", "b"}', "range(24)", "sorted(WORLDS)", "WORLDS"], + ) + def test_a_table_that_cannot_be_empty_is_not_nominated(self, argvalues, tmp_path): + """The acquittals matter more than the flags. A literal cannot vanish, + and neither can a module constant bound to one.""" + rows = _nominate( + f""" + import pytest + + WORLDS = ["a", "b"] + + @pytest.mark.parametrize("item", {argvalues}) + def test_each(item): + assert item + """, + tmp_path, + ) + assert rows == [], rows + + def test_a_name_bound_to_something_UNKNOWN_is_still_nominated(self, tmp_path): + """Mutation control (M18, survived the first run). Acquitting every + module-level name would wave through a table built by a call at import + time — the same query, one line higher up.""" + rows = _nominate( + """ + import pytest + + def collect(): + return [] + + WORLDS = collect() + + @pytest.mark.parametrize("item", WORLDS) + def test_each(item): + assert item + """, + tmp_path, + ) + assert len(rows) == 1, rows + + def test_an_EMPTY_literal_is_still_nominated(self, tmp_path): + """Negative control for the acquittal above: `[]` written out is the + defect in its most obvious form, and a rule that acquitted every + literal would wave it through.""" + rows = _nominate( + """ + import pytest + + @pytest.mark.parametrize("item", []) + def test_each(item): + assert item + """, + tmp_path, + ) + assert len(rows) == 1 + + def test_a_nonempty_only_guard_is_nominated_as_a_SHORT_TABLE(self, tmp_path): + """THE RULING CHANGED IN ROUND 9 and this pin records the change. + + It used to assert a full acquittal: a file asserting its collection is + non-empty had done the thing this rule asks for. @trigger found the + hole by applying my own sentence to their own pins - a guard asserting + non-emptiness catches a collector blinded ENTIRELY and misses one that + drops a single entry. The table stays non-empty, every surviving case + passes, and the run is quietly short. + + Measured before the split: 10 files fleet-wide are acquitted by the + guard clause, 7 already pin a count, 3 do not. Three sites, not a tree. + """ + rows = _nominate( + """ + import pytest + + def collect(): + return [] + + def test_the_collector_found_something(): + assert len(collect()) > 0 + + @pytest.mark.parametrize("item", collect()) + def test_each(item): + assert item + """, + tmp_path, + ) + assert [r["species"] for r in rows] == ["SHORT-TABLE"], rows + + def test_a_guard_that_pins_an_expected_COUNT_is_acquitted(self, tmp_path): + """The full acquittal, and the shape @trigger shipped: the expected + number derived from the RAW DATA rather than from the collector under + judgement, so a dropped entry has something to fail against.""" + rows = _nominate( + """ + import json + import pytest + + def collect(): + return [] + + def test_the_collector_found_them_all(): + raw = json.loads(RAW.read_text())["bypass"] + expected = sum(1 for rule in raw if rule.get("lines")) + assert len(collect()) == expected + + @pytest.mark.parametrize("item", collect()) + def test_each(item): + assert item + """, + tmp_path, + ) + assert rows == [] + + def test_len_compared_against_ZERO_is_not_a_count_guard(self, tmp_path): + """`len(x) == 0` is an emptiness assertion wearing a count's shape, and + acquitting on it would hand a full pass to the one comparison that + proves the table IS empty.""" + rows = _nominate( + """ + import pytest + + def collect(): + return [] + + def test_nothing_left_over(): + assert len(collect()) == 0 + + @pytest.mark.parametrize("item", collect()) + def test_each(item): + assert item + """, + tmp_path, + ) + assert [r["species"] for r in rows] == ["SHORT-TABLE"], rows + + def test_a_safe_builtin_over_a_CALL_is_still_nominated(self, tmp_path): + """The unwrap goes one layer and judges what it finds. sorted() of a + query is still a query.""" + rows = _nominate( + """ + import pytest + + def collect(): + return [] + + @pytest.mark.parametrize("item", sorted(collect())) + def test_each(item): + assert item + """, + tmp_path, + ) + assert len(rows) == 1 + + +class TestTheNominatorShape: + def test_it_satisfies_the_pack_contract(self): + """Law S7a and the shape gate: a nominator that exposed check_module + would be scored by the file-walk engine as if it were a standard.""" + from aipass.seedgo.apps.handlers.tests_pytest_standards import nominators + + assert nominators.shape_problems(empty_parametrize_check) == [] + + def test_the_pack_discovers_it(self): + from aipass.seedgo.apps.handlers.tests_pytest_standards import nominators + + modules, errors = nominators.discover() + assert errors == [] + assert "static_empty_parametrize" in modules + + def test_the_specification_names_the_cure_not_just_the_defect(self): + """A nomination whose fix is 'do not do that' teaches nothing. The + cure has to say why the obvious probe does not work.""" + fix = empty_parametrize_check.SPECIFICATION["fix"] + assert "raw data" in fix + assert "cannot detect a blinded collector" in fix + + def test_the_module_parses_and_declares_its_limits(self): + """Law S8: a score without a declared blind spot is a refusal.""" + assert empty_parametrize_check.SPECIFICATION["limits"] + source = Path(empty_parametrize_check.__file__).read_text(encoding="utf-8") + ast.parse(source) diff --git a/src/aipass/seedgo/tests/test_import_dead_cwd.py b/src/aipass/seedgo/tests/test_import_dead_cwd.py new file mode 100644 index 000000000..66ea57489 --- /dev/null +++ b/src/aipass/seedgo/tests/test_import_dead_cwd.py @@ -0,0 +1,2798 @@ +# =================== AIPass ==================== +# Name: test_import_dead_cwd.py +# Description: Pins seedgo imports against a working directory the OS cannot read +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Every seedgo module must import without a readable working directory. + +TWO DEFECTS, TWO WORLDS — and one instrument would have proved only half. + +WORLD A, the ntpath condition. ``ntpath.realpath`` calls ``os.getcwd()`` +UNCONDITIONALLY, before it checks whether the path is even relative, and +``Path.resolve()`` routes through it. So on Windows every module-level +``Path(__file__).resolve()`` is an import-time working-directory read. Twelve +seedgo modules had one. Injected as the CONDITION rather than the platform: +``os.path.realpath`` is wrapped to read ``os.getcwd()`` first, then ``getcwd`` +is denied. + +WORLD B, the inspect.stack() shape. ``handlers/__init__.py`` called +``inspect.stack()`` before anything else. That builds a FrameInfo per frame -> +``getsourcefile`` -> (only for the frozen importlib frames an import puts on the +stack) ``getmodule``, whose module-scan loop calls ``os.path.realpath`` OUTSIDE +any try. World A CANNOT catch this one: on POSIX the walk raises earlier inside +``getabsfile``, where ``inspect`` catches ``FileNotFoundError`` and returns +None — so a getcwd denial leaves the defective guard green while Windows dies, +because ``ntpath.abspath`` succeeds there and control reaches the unprotected +realpath. World B injects that asymmetry directly: ``abspath`` keeps working, +``os.path.realpath`` raises. + +Both were REPRODUCED RED ON LINUX against the pre-fix source before the cure was +written; neither needed a Windows box. Measured on the Windows CI gate +2026-08-31 (@memory's finding for A, @spawn's for B, relayed by @devpulse). + +THE INTERPRETER VERSION IS PART OF THE PLATFORM. Round 6, from CI: both worlds +went red on the Python 3.10 leg and neither was a defect in seedgo. 3.10's pathlib +delegates ``Path.resolve()`` to ``os.path.realpath`` through a ``_NormalAccessor`` +that CAPTURED its own reference when pathlib was first imported (CPython 3.10 +pathlib.py:358, called at :1077). Rebinding ``os.path.realpath`` afterwards +rebinds a name nothing reads again, so world A was inert there — not because the +delegation is missing, which was the first diagnosis and was wrong (@memory read +the source and refuted it). The arming probes refused rather than passing +quietly, which is the instrument discipline working; what they could not do was +still measure the claim. So: + + * world A stays as the faithful ntpath emulation, keyed to a per-version + cured in four lines: it patches the CAPTURED ACCESSOR too, so it arms on + every interpreter and needs no version table at all; + * ``ARM_WORLD_A_PRIME`` patches ``Path.resolve`` itself — the public call the + defect makes, not the private delegate it routes through this year — so it + arms on every version, and the module sweep rides it; + * ``EMULATE_PY310_PATHLIB`` rebuilds the capture so the whole claim is + falsifiable on THIS machine. A row no local platform can contradict enters + silently (@ai_mail, round 5), and a CI red is a negative measurement: it + proves not-armed, never why — which is precisely how the first, wrong + mechanism survived a careful write-up. + +World B needed no change — ``inspect`` calls ``os.path.realpath`` directly on +every version. Its ARMING PROBE was the defect: it asked ``Path.resolve()``, a +question about pathlib, while the world denies realpath. Identical answers on +3.11+ by delegation, divergent on 3.10. Deny and measure the call the DEFECT +makes (@memory's rule, applied to a probe rather than to a world). + +Each world carries a POSITIVE CONTROL — a module rebuilt in the defective shape +and imported live, which must die — and a NEGATIVE CONTROL FOR THE POSITIVE +CONTROL: the same module must import cleanly in the healthy world. @spawn's +lesson from the same round: a control that dies for any reason turns every pin +above it vacuously green, so the control needs a control. +""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +SEEDGO_ROOT = Path(__file__).resolve().parents[1] + +# Other branches' import-time code is held CONSTANT: imported in the healthy +# world, before any denial. Their dead-cwd cure is their own build (fleet +# rollout in flight, 2026-08-31); this pin measures SEEDGO's sites only. +# TEMPORARY — delete each line as its branch is cured, and the pin gets stricter +# for free. These ten are exactly the foreign entry points seedgo's own source +# names; the rest of each chain rides in behind them. +# THE CHILDREN OF THIS FILE RUN WITH THEIR CWD AT THE REPO ROOT (see _run), so +# anything any world here writes to a RELATIVE path lands in the working tree. +# That is not hypothetical: a round-10 sweep instrument installed an nt-shaped +# os.path before the preload, every absolute path in the child came back +# spelled with backslashes, and posix - where a backslash is an ordinary +# filename character - created 45 entries named "\\tmp\\..." at the repo root. +# @spawn and @devpulse both measured them from the outside before I saw them. +# +# The shipped worlds mint nothing (measured: zero new entries across a full +# run). The detector exists because the next world might, and a directory +# quietly appearing in the working tree is exactly the class of thing an +# indiscriminate stage-everything turns into a commit. +CHILD_CWD = SEEDGO_ROOT.parents[2] + + +def _working_tree_entries(root: Path) -> set: + """Names directly under `root`, or an empty set if it is not readable. + + Names, not paths: the point is what APPEARED, and a name carrying a + backslash is the whole reason this exists. + """ + try: + return {entry.name for entry in root.iterdir()} + except OSError: + return set() + + +def _litter(before: set, after: set) -> set: + """What this file's children added to the working tree. A plain function + over two sets, so both the empty and the convicting case are reachable + without minting anything.""" + return after - before + + +@pytest.fixture(scope="module", autouse=True) +def _no_working_tree_litter(): + """Fail the module if its children left anything in the repo root. + + Deliberately does NOT delete what it finds: `drone rm` is the sanctioned + way to remove anything here, and a detector that tidies up after itself + destroys the evidence of the world that produced it. + """ + before = _working_tree_entries(CHILD_CWD) + yield + new = _litter(before, _working_tree_entries(CHILD_CWD)) + assert not new, ( + "a world in this file wrote into the working tree - the children run with " + f"cwd at the repo root, so a relative path lands there: {sorted(new)}" + ) + + +PRELOAD = """ +import aipass.prax # noqa: F401 +import aipass.prax.apps.modules.logger # noqa: F401 +import aipass.prax.apps.handlers.logging.setup # noqa: F401 +import aipass.cli # noqa: F401 +import aipass.cli.apps.modules # noqa: F401 +import aipass.cli.apps.modules.display # noqa: F401 +import aipass.drone.apps.modules # noqa: F401 +import aipass.spawn.apps.modules # noqa: F401 +import aipass.aipass.shared # noqa: F401 +import aipass.aipass.shared.json_handler # noqa: F401 +""" + +# The two shapes the cure deleted, rebuilt verbatim. Written to disk and +# imported live rather than reasoned about: the question is whether the world +# still kills the OLD code, and only the old code can answer it. +DEFECT_A_SOURCE = "from pathlib import Path\nX = Path(__file__).resolve()\n" +DEFECT_B_SOURCE = "import inspect\nX = inspect.stack()\n" +#: The second captured route. Path.cwd() reaches _accessor.getcwd before +#: 3.11, never os.getcwd directly, so this defect and DEFECT_A_SOURCE are +#: convicted by DIFFERENT halves of the same world (@skills, round 7). +DEFECT_C_SOURCE = "from pathlib import Path\nX = Path.cwd()\n" + +ARM_WORLD_A = """ +import os + +_real_realpath = os.path.realpath + + +def _ntpath_condition(path, **kw): + os.getcwd() # ntpath.realpath reads cwd before checking absoluteness + return _real_realpath(path, **kw) + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.path.realpath = _ntpath_condition + +# THE CAPTURED ACCESSOR. Before 3.11, pathlib's _NormalAccessor took its OWN +# reference at class-creation time — `realpath = staticmethod(os.path.realpath)`, +# CPython 3.10 pathlib.py:358 — and Path.resolve called it as +# `self._accessor.realpath(self, strict=strict)` (line 1077). So 3.10 DOES +# delegate to os.path.realpath; it just holds a copy taken when pathlib was +# first imported, and rebinding the module attribute afterwards rebinds a name +# nothing will read again. That, not a missing delegation, is why this world was +# inert on the 3.10 CI leg (@memory read the source and refuted the first +# diagnosis; relayed by @devpulse). staticmethod, not a plain function: bound as +# a method it would swallow the path argument into self. +try: + import pathlib as _pathlib_for_accessor + + _pathlib_for_accessor._NormalAccessor.realpath = staticmethod(_ntpath_condition) + # The OTHER captured route: Path.cwd() is cls(cls._accessor.getcwd()) before + # 3.11, a separate attribute holding a separate copy. Curing realpath alone + # leaves a module-level Path.cwd() unconvicted on 3.10 — the world would + # deny a getcwd nothing reads (@skills, round 7). + _pathlib_for_accessor._NormalAccessor.getcwd = staticmethod(_dead_getcwd) +except AttributeError: + pass # 3.11+ removed the accessor and calls os.path.realpath at use. + +os.getcwd = _dead_getcwd +""" + +# WORLD A-PRIME, the same condition injected one level UP. World A wraps +# ``os.path.realpath`` because that is what ``ntpath`` does and what Windows +# actually runs, and until the accessor cure above it went inert on 3.10 — +# measured by CI on the 3.10 leg of 8550ed10, where the arming probe reported +# DEFECT_SURVIVED rather than the pin passing quietly (@devpulse, 2026-08-31). +# +# THE SENTENCE THAT USED TO BE HERE WAS WRONG, and it is corrected rather than +# deleted because the wrong mechanism is the lesson. It read: "pathlib only +# delegates to os.path.realpath from 3.11; on 3.10 it carried its own resolve, +# so the wrapper is never reached." 3.10 DOES delegate — through an accessor +# holding a reference captured at import (pathlib.py:358, called at 1077), which +# is why rebinding the module attribute alone changed nothing. @memory read the +# CPython source and refuted it. A CI red is a NEGATIVE measurement: it says +# not-armed and never why, and that gap is exactly where a plausible mechanism +# moves in and settles into a comment. +# +# The interpreter VERSION is part of the platform, exactly as os.name is. +# This world patches ``Path.resolve`` ITSELF — the public call the defect makes, +# not the private delegate it happens to route through this year — so it arms on +# every version by construction. It is the STAND-IN; world A stays because it is +# the faithful ntpath emulation, and the two are pinned to AGREE wherever both +# arm, so the stand-in expires the day they diverge (@memory's licence, ratified +# round 3). +ARM_WORLD_A_PRIME = """ +import os +import pathlib + +_real_resolve = pathlib.Path.resolve + + +def _windows_shaped_resolve(self, strict=False): + # The PROPERTY ntpath gives Windows: resolve() reads the working directory + # whether or not the path is relative. Injected at the public call so no + # pathlib internal is assumed. + os.getcwd() + return _real_resolve(self, strict=strict) + + +pathlib.Path.resolve = _windows_shaped_resolve + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd +""" + +ARM_WORLD_B = """ +import os + + +def _dead_realpath(path, **kw): + # ntpath.realpath's first act is a cwd read; abspath still SUCCEEDS on + # Windows, which is exactly why inspect reaches this call unprotected. + raise FileNotFoundError(2, "realpath needs a cwd", "") + + +os.path.realpath = _dead_realpath +""" + +# Does THIS interpreter's Path.resolve() reach the denied call for an absolute +# path? 3.11+ routes through os.path.realpath; older ones short-circuit. Pinned +# as a probe with BOTH outcomes reported, never a skipif — the vacuous world is +# named in the output rather than hidden by a skip. +PROBE = """ +import pathlib + +try: + pathlib.Path(pathlib.__file__).resolve() + print("PROBE_VACUOUS") +except OSError: + print("PROBE_ARMED") +""" + +# World B denies ``os.path.realpath``, so the probe for it must MEASURE +# os.path.realpath. The first version used the resolve probe above, which asks a +# question about pathlib instead — true on 3.11+ by delegation, false on 3.10, +# and the world-B pin went red on the 3.10 CI leg for a property world B never +# depended on. @memory's rule, applied to a probe rather than to a world: deny +# and measure the call the DEFECT actually makes. inspect calls +# os.path.realpath directly on every version. +PROBE_REALPATH = """ +import os + +try: + os.path.realpath(os.__file__) + print("PROBE_VACUOUS") +except OSError: + print("PROBE_ARMED") +""" + +# NO VERSION TABLE, and the first cut of this file had one. When the 3.10 CI leg +# went red the diagnosis on hand was "pathlib does not delegate before 3.11", and +# a two-row expectation table keyed on sys.version_info followed from it — every +# row falsifiable, no skipif, all the round-5 discipline correctly applied to a +# premise that was WRONG. @memory fetched CPython 3.10's pathlib and refuted it: +# it delegates, through a captured accessor. The cure is four lines in the world +# above and the table it would have justified does not need to exist. +# +# Keeping the note because the failure is instructive and cheap to repeat: a +# careful table built on an unverified mechanism is more durable than a guess, +# and therefore worse. The mechanism gets read from the source before the +# instrument is keyed to it. + + +# THE OPPOSITE-PLATFORM LITMUS, as a WORLD so it can ride into any child. +# +# Emulated BY PROPERTY, never by aliasing ntpath. On a posix host +# ntpath.realpath IS ntpath.abspath, so an alias emulates the host wearing an nt +# label and produces a green-looking answer that has silently stopped +# reproducing anything (@flow's M3 trap, failure mode measured by @drone; +# relayed round 7). The property that actually differs is one line: +# ntpath.realpath reads os.getcwd() unconditionally, for absolute paths too +# (ntpath.py:678), where posixpath reads it only for relative ones. +# +# @drone's addendum is why this is a TEST and not a one-time check: a pin that +# reads a value back can be measuring the host, so the litmus lives BESIDE the +# pin rather than in a commit message about a day someone once ran it. +EMULATE_NT_REALPATH = """ +import os + +_posix_realpath_before_nt = os.path.realpath + + +def _nt_shaped_realpath(path, *a, **kw): + # ntpath.py:678 - unconditional, even when the path is already absolute. + os.getcwd() + return _posix_realpath_before_nt(path, *a, **kw) + + +os.path.realpath = _nt_shaped_realpath +""" + + +# World A WITHOUT the accessor cure — the shape that went red on the 3.10 CI +# leg, kept as the negative control for the four lines that fixed it. +BARE_MODULE_PATCH_ONLY = """ +import os + +_real_realpath_bare = os.path.realpath + + +def _ntpath_condition_bare(path, **kw): + os.getcwd() + return _real_realpath_bare(path, **kw) + + +os.path.realpath = _ntpath_condition_bare + + +def _dead_getcwd_bare(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd_bare +""" + +# EQUIVALENT MUTANT, recorded so nobody spends the evening on it twice. Binding +# os.getcwd to something harmless inside EMULATE_NT_REALPATH survives every pin +# — because both worlds above rebind os.getcwd to a denier as their LAST line, +# and the emulation is concatenated FIRST. It is overwritten before anything +# runs, so the mutation has no behaviour to change. Run round 7, M5. + + +# A HOST THAT IS ALREADY 3.10-SHAPED, so the round-8 red is falsifiable HERE. +# +# The emulation below exists to give a 3.11+ host the pre-3.11 shape. On CI's +# real 3.10 leg the host ALREADY had it - and the emulation replaced a +# fully-featured accessor with a two-method stand-in, so pathlib's own API lost +# the methods it routes through and every module import died on +# `AttributeError: '_NormalAccessor' object has no attribute 'mkdir'`. +# +# Emulated BY PROPERTY, exactly as the nt world is: the discriminating fact is +# that a pre-3.11 pathlib ROUTES THROUGH the accessor (mkdir at pathlib.py:1175 +# is `self._accessor.mkdir(self, mode)`), not merely that the attribute exists. +# A host that only carries the attribute is not the shape - the first version of +# this reproduction did exactly that and both arms came back green, which is the +# alias trap one dimension over: an emulation that quietly agrees with its host +# is indistinguishable from a cure. +EMULATE_NATIVE_ACCESSOR_HOST = """ +import os +import pathlib + +# THE HOST MAY ALREADY BE ONE. Round 8 cured EMULATE_PY310_PATHLIB of replacing +# a real accessor and I built its reproduction partner with the identical flaw: +# a three-method stand-in installed over CPython 3.10's real _NormalAccessor, +# which routes stat, listdir, open and the rest through it. On 3.12 nothing +# reads the attribute so both arms looked right; on the real 3.10 leg of +# 9bd2618b every pin riding this world died on +# `AttributeError: '_NativeAccessor' object has no attribute 'stat'`. +# +# THE SAME DEFECT, ONE FILE OVER, IN THE WORLD BUILT TO REPRODUCE IT. The cure +# is the same too, and it is now applied in both directions: on a host that +# already has the shape, do nothing and say so. +if hasattr(pathlib, "_NormalAccessor"): + print("NATIVE_HOST_ALREADY") +else: + + class _NativeAccessor: + # Stands in for CPython 3.10's real _NormalAccessor: MANY methods, not + # three. Anything not named here DELEGATES to the os function of the + # same name, so this stand-in cannot decapitate a host by omission - + # which is exactly how its first version failed. + realpath = staticmethod(os.path.realpath) + getcwd = staticmethod(os.getcwd) + mkdir = staticmethod(os.mkdir) + + def __getattr__(self, name): + for source in (os, os.path): + found = getattr(source, name, None) + if found is not None: + return found + raise AttributeError(name) + + def _pre_311_mkdir(self, mode=0o777, parents=False, exist_ok=False): + # CPython 3.10 pathlib.py:1175, INCLUDING the parents/exist_ok handling + # around it. The first version called the accessor and stopped there, so + # mkdir(parents=True, exist_ok=True) raised the moment the directory + # existed - green alone, red in a full suite where a temp log dir is + # already there. Order-dependence hid an incomplete stand-in. + try: + self._accessor.mkdir(str(self), mode) + except FileNotFoundError: + if not parents or self.parent == self: + raise + self.parent.mkdir(parents=True, exist_ok=True) + self.mkdir(mode, parents=False, exist_ok=exist_ok) + except OSError: + if not exist_ok or not self.is_dir(): + raise + + pathlib._NormalAccessor = _NativeAccessor + pathlib._normal_accessor = _NativeAccessor() + pathlib.Path._accessor = pathlib._normal_accessor + pathlib.Path.mkdir = _pre_311_mkdir + print("NATIVE_HOST_SYNTHESISED") +""" + + +#: A host shaped like a real pre-3.11 interpreter: an accessor that pathlib +#: ROUTES stat through, which is what made the round-9 3.10 red possible and +#: what no amount of 3.12 testing could produce. Kept so the red is falsifiable +#: here forever rather than on a leg nobody runs locally. +EMULATE_REAL_310_ACCESSOR_HOST = """ +import os +import pathlib + + +class _RealAccessor: + realpath = staticmethod(os.path.realpath) + getcwd = staticmethod(os.getcwd) + mkdir = staticmethod(os.mkdir) + stat = staticmethod(os.stat) + + +def _pre_311_stat(self, follow_symlinks=True): + # CPython 3.10 pathlib.py:1097 - self._accessor.stat(self, ...) + return self._accessor.stat(str(self)) + + +pathlib._NormalAccessor = _RealAccessor +pathlib._normal_accessor = _RealAccessor() +pathlib.Path._accessor = pathlib._normal_accessor +pathlib.Path.stat = _pre_311_stat +""" + +#: An nt-shaped host for the ONE question the alias check turns on: on Windows +#: ``os.path`` IS ``ntpath`` - one module, not two - so aliasing ntpath over +#: os.path changes nothing and no probe can tell the alias from the real thing. +#: +#: Both halves are needed and each was measured: the module IDENTITY (without +#: it the alias is still foreign here) and the unconditional cwd read that a +#: real nt realpath performs (without it the aliased call returns early for an +#: absolute path and the row reads CATCHABLE, which is the Linux answer). +#: +#: The replacement is a SENTINEL that returns its argument - the round-7 rule, +#: because anything it borrowed from posixpath would be behaviour this row does +#: not test, and an emulation friendlier than the thing it stands in for hides +#: the defect. +#: The two halves are named separately because the control below measures each +#: one alone, and because WHICH object each patches is the whole reason this +#: world works where ``EMULATE_NT_REALPATH`` cannot: the alias world's last act +#: is ``os.path.realpath = ntpath.realpath``, so a host that patched +#: ``os.path.realpath`` is overwritten before the probe runs. Patching +#: ``ntpath.realpath`` is what survives being aliased over. +_NT_HOST_UNCONDITIONAL_CWD = """ +import ntpath +import os + + +def _nt_shaped_realpath_sentinel(path, *a, **kw): + os.getcwd() # ntpath.py:678 - unconditional, absolute paths included + return path + + +ntpath.realpath = _nt_shaped_realpath_sentinel +""" + +_NT_HOST_MODULE_IDENTITY = """ +import ntpath +import os +import sys + +os.path = ntpath +sys.modules["os.path"] = ntpath +""" + +EMULATE_NT_HOST_IDENTITY = _NT_HOST_UNCONDITIONAL_CWD + _NT_HOST_MODULE_IDENTITY + +#: The MIRROR of the world above, and the reason it exists: a row that wants to +#: exercise both starting dialects cannot get the second one by leaving the host +#: alone. On this box an untouched child starts as posixpath and the nt world +#: supplies the other side - but on a windows runner BOTH children start as +#: ntpath and the pair silently collapses into one arm, which is the same +#: species as an assertion spelling its own host's answer, one level up: not a +#: wrong expectation, a missing second measurement. Forcing each side means the +#: pair is two arms on every host, including ones nobody here runs. +#: +#: Deliberately identity ONLY. Nothing here claims the child is a posix +#: PLATFORM - on nt this makes the chimera in the other direction - and the one +#: row that uses it reads only which way a later break turned. +_POSIX_HOST_MODULE_IDENTITY = """ +import os +import posixpath +import sys + +os.path = posixpath +sys.modules["os.path"] = posixpath +""" + +#: A FLAVOUR THAT IS AN OBJECT, which is what 3.10 and 3.11 actually carry and +#: what made round 10's routing pin red on three legs. Meant to be stacked on +#: EMULATE_PY310_PATHLIB, which redirects resolve through the accessor first - +#: without that, a 3.12 pathlib would ask this object for realpath and the +#: emulation would decapitate the interpreter instead of shaping it. +#: +#: It REFUSES realpath by name. CPython 3.10's _PosixFlavour carries parsing +#: and nothing else, so a stand-in that answered would be friendlier than the +#: thing it stands in for - @spawn's round-10 lesson, applied where it bites. +EMULATE_OBJECT_FLAVOUR_HOST = """ +import pathlib +import posixpath + + +class _ObjectFlavour: + sep = posixpath.sep + altsep = "" + has_drv = False + pathmod = posixpath + + def __getattr__(self, name): + if name == "realpath": + raise AttributeError("a 3.10 flavour has no realpath; resolve goes through the accessor") + return getattr(posixpath, name) + + +# THE HOST MAY ALREADY BE THIS SHAPE - 3.10 and 3.11 are - and installing over +# a real _PosixFlavour REMOVES behaviour this world is not testing: the real one +# answers parse_parts, casefold and make_uri, none of which posixpath has, so +# the delegation below decapitates the very interpreter it is imitating. +# +# ROUND 11 SHIPPED THIS CHECK AGAINST THE WRONG CLASS. It read PurePath, where +# 3.10 and 3.11 do not define _flavour at all - the concrete PurePosixPath does +# - so getattr answered None, the else branch ran, and CI's 3.11 leg died in +# pathlib._parse_args on `module 'posixpath' has no attribute 'parse_parts'`. +# The stand-down existed and was looking at the wrong object; a guard that reads +# the wrong attribute is indistinguishable from no guard at all. +# +# Read from the CONCRETE class the world would replace, and answer three ways, +# because a pathlib with no _flavour at all (3.13 spells it parser) is not a +# host to stand down from - it is a question this world cannot ask. +_existing = getattr(pathlib.Path, "_flavour", None) +if _existing is None: + print("OBJECT_FLAVOUR_UNAVAILABLE: this pathlib has no _flavour to stand in for") +elif not isinstance(_existing, type(posixpath)): + print("OBJECT_FLAVOUR_NATIVE") +else: + pathlib.Path._flavour = _ObjectFlavour() + pathlib.PurePath._flavour = pathlib.Path._flavour + print("OBJECT_FLAVOUR_INSTALLED") +""" + +#: A 3.13-SHAPED RESOLVE, built from the round-11 CI evidence rather than from a +#: source line I can read on this box (this machine has only 3.12). +#: +#: WHAT THE EVIDENCE SAYS: on the real 3.13 runner the chimera fails to disarm +#: world A - DEFECT_DIED: FileNotFoundError, verbatim identical to the 3.10 leg +#: (@devpulse, round-11 addendum) - even though 3.13 has no _NormalAccessor and +#: no _flavour, so the captured-accessor mechanism cannot be the reason. +#: +#: The shape that produces that answer is a resolve which reads ``os.path`` AT +#: CALL TIME instead of through a class attribute: pointing os.path at ntpath +#: then MOVES the route rather than breaking it, and the patch rides along. This +#: world installs exactly that and nothing else, so the arm below is a +#: measurement here instead of a prediction about an interpreter I cannot run. +EMULATE_313_RESOLVE_READS_OS_PATH = """ +import os +import pathlib + + +def _resolve_313(self, strict=False): + # The distinguishing property: os.path is looked up NOW, so whatever it + # points at is the route. + return pathlib.Path(os.path.realpath(str(self), strict=strict)) + + +pathlib.Path.resolve = _resolve_313 +print("RESOLVE_READS_OS_PATH_AT_CALL_TIME") +""" + + +#: BREAK THE ROUTING IDENTITY, TOWARD THE DIALECT THIS HOST IS NOT. +#: +#: The fixed chimera - always point os.path at ntpath - is a NO-OP on nt, where +#: os.path already IS ntpath. Windows measured that: the probe honestly reported +#: the identity intact and the control convicted it for reading the world +#: correctly. It is the third reason the round-11 disarm control already names, +#: arriving one control over (@devpulse, round-12 addendum). +#: +#: So the direction is chosen from what the host IS, and the child says which +#: way it went - a break that cannot happen must not look like a break that +#: found nothing. +BREAK_THE_ROUTING_IDENTITY = """ +import ntpath +import os +import posixpath +import sys + +# WHERE THE CHILD STARTED, printed before anything moves. A row that spelled +# the starting dialect would be spelling the host it was written on: this box +# starts as posixpath, a windows runner starts as ntpath, and the nt-identity +# emulation starts as ntpath too. The direction is a JUDGEMENT over this +# reading, made by the reader, not a literal in the assertion. +print("IDENTITY_STARTED_AS:", os.path.__name__) +_toward = posixpath if os.path is ntpath else ntpath +os.path = _toward +sys.modules["os.path"] = _toward +print("IDENTITY_BROKEN_TOWARD:", _toward.__name__) +""" + + +def _break_direction(started_as: str) -> str: + """Which dialect the break must land on, given where the child started. + + The whole content of the addendum in one function: break TOWARD the dialect + the host is not. Both rows are reachable from any host - this one by + reading, the other by installing the nt-identity emulation first - so + neither arm waits on a runner nobody here has. + """ + return "posixpath" if started_as == "ntpath" else "ntpath" + + +def _break_can_move_the_identity(flavour_is_a_dialect_module: bool) -> bool: + """Whether `flavour is os.path` can move at all on this host. + + The reading compares pathlib's routing attribute against os.path. Moving + os.path between the two dialect modules changes that comparison only if the + routing attribute IS one of them. On 3.10 and 3.11 it is a _PosixFlavour + OBJECT and on 3.13 there is no _flavour at all, so the reading is False + before the break and False after it - not because the break failed, but + because the question does not apply. Round 12's own lesson, one world + later: a row that cannot be asked on a host must report, not assert. + """ + return flavour_is_a_dialect_module + + +#: Make this child a host with NO accessor, whatever interpreter it is running +#: on. Round 9's second red: `test_the_emulation_still_TAKES_on_a_host_without +#: _one` asserted ACCESSOR_EMULATED unconditionally, which is only true where +#: the host has no accessor - a 3.12 fact, and on the real 3.10 leg the host +#: correctly reported ACCESSOR_NATIVE and the pin died for being right. +#: +#: Emulating the ABSENCE is what makes both arms reachable on every +#: interpreter, rather than each leg testing whichever arm it happens to have. +REMOVE_ANY_NATIVE_ACCESSOR = """ +import pathlib + +if hasattr(pathlib, "_NormalAccessor"): + del pathlib._NormalAccessor +""" + +#: THE 3.11 HYBRID, which is neither of the two shapes this file had names for. +#: +#: 3.11 removed the captured accessor (like 3.12+) while its flavour is still an +#: OBJECT (like 3.10), and the route is the call-time module read. Round 11's +#: routing table read those two attributes as proxies for the route and had no +#: row for the combination, so a shipped interpreter came back as +#: NO_ROUTE_EXPLAINS_THIS_HOST - a table reporting "not in my table" as a +#: verdict (@devpulse, round 12). +#: +#: Built by composition rather than by a new emulation: the object flavour and +#: the call-time resolve are already here, and each stands down where the host +#: already has that half, so this constant is the shape and not a second +#: implementation of it. +#: +#: The accessor removal is the third piece and it is not optional. On a host +#: that HAS a captured accessor - 3.10, or any child where an emulation put one +#: there - the other two halves cannot produce the hybrid, and the row asserting +#: "no accessor" would be red on the very interpreter it is meant to speak +#: about. Every half of this host is constructed rather than inherited. +EMULATE_311_HYBRID_HOST = REMOVE_ANY_NATIVE_ACCESSOR + EMULATE_OBJECT_FLAVOUR_HOST + EMULATE_313_RESOLVE_READS_OS_PATH + + +# A 3.10-SHAPED pathlib, so the accessor claim is falsifiable HERE. +# +# @ai_mail's round-5 finding, one dimension over: a table row no local platform +# can contradict ENTERS SILENTLY. "3.10 cannot arm world A" arrived as a CI red +# and would otherwise sit in this file unchallenged by every local run, which is +# the original defect's shape wearing a version number. +# +# So the 3.10 CALL CHAIN is emulated rather than the version asserted: resolve() +# is rebuilt to read the cwd itself and normalise without ever touching +# os.path.realpath, which is what pathlib did before 3.11. Under it world A must +# go inert and world A-prime must still convict — the same discrimination the +# 3.10 CI leg reported, reproduced on 3.12. +EMULATE_PY310_PATHLIB = """ +import os +import pathlib + + +def _captured_sentinel(path, *a, **kw): + # A SENTINEL, not the real os.path.realpath. What this emulation is FOR is + # the capture — that a reference taken at class-creation time cannot see a + # later rebinding — and the identity of the captured function is beside the + # point. Capturing the real one imported behaviour this file does not test: + # on Windows ntpath.realpath reads os.getcwd() UNCONDITIONALLY (ntpath.py + # :678, even for an absolute path, which is the posixpath fact that does not + # travel), so under the getcwd denial the bare-patch control raised and the + # pin read DEFECT_DIED. It died for the PLATFORM, not for the mechanism. + # Measured on the round-6 windows-setup leg of c82c3d34 and reproduced here + # by emulating that property (@memory's sentinel pattern, relayed round 7). + # + # Touching nothing means anything that raises afterwards is the patch's + # doing, on any host. My own round-6 rule said absolute targets are + # load-bearing; the half I missed is that "absolute never reads the cwd" is + # a posixpath fact, not a portable one. + return path + + +def _captured_getcwd(): + # The second sentinel. Returns a fixed absolute string rather than calling + # the real os.getcwd, for the same reason as its sibling: this emulation is + # about the CAPTURE, and anything it borrows from the host is a property the + # pin did not mean to depend on. + return os.sep + "captured" + + +class _NormalAccessor: + # CPython 3.10 pathlib.py:358. The capture is the whole point: this holds + # the function object NAMED when the class body ran, and a later rebinding + # of that module attribute cannot be seen from here. + # + # TWO captured routes, not one. Path.cwd() is cls(cls._accessor.getcwd()) + # before 3.11 and Path.resolve() goes through _accessor.realpath — separate + # attributes, each with its own capture. Arming one says nothing about the + # other, and a world that cures only realpath still lets a module-level + # Path.cwd() sail through on 3.10 (@skills, who found it as a live mutant + # survivor: deleting their realpath patch changed nothing because every pin + # underneath was riding the getcwd half). + realpath = staticmethod(_captured_sentinel) + getcwd = staticmethod(_captured_getcwd) + + +_normal_accessor = _NormalAccessor() + +# THE HOST MAY ALREADY BE THIS SHAPE, and if it is, emulating DESTROYS it. +# +# Measured on the round-8 3.10 leg of 68ab5132: a real 3.10 pathlib routes its +# whole API through the accessor (mkdir at pathlib.py:1175 is +# `self._accessor.mkdir(self, mode)`), so replacing a fully-featured accessor +# with this two-method stand-in decapitated the interpreter and every module +# import died on `AttributeError: '_NormalAccessor' object has no attribute +# 'mkdir'`. The pin was red for the emulation, not for the defect. +# +# The general rule this file had already learned once and did not apply here: an +# instrument must not import behaviour it is not testing - and the mirror image +# is that it must not REMOVE behaviour it is not testing either. On a host that +# already has the pre-3.11 shape there is nothing to emulate; the host IS the +# subject, and the emulation's whole job is to give a 3.11+ host that shape. +# +# Which arm ran is PRINTED rather than assumed, because a silent no-op and a +# silent replacement look identical from downstream (@spawn's ROUTE_ARMED / +# ROUTE_DARK vocabulary, arrived at independently on the same CI board). +if getattr(pathlib, "_NormalAccessor", None) is not _NormalAccessor and hasattr(pathlib, "_NormalAccessor"): + print("ACCESSOR_NATIVE") +else: + pathlib._NormalAccessor = _NormalAccessor + pathlib._normal_accessor = _normal_accessor + + def _pre_311_resolve(self, strict=False): + # CPython 3.10 pathlib.py:1077 - called through the INSTANCE, which is + # why a world must patch the class attribute the instance falls + # through to. + return pathlib.Path(_normal_accessor.realpath(str(self), strict=strict)) + + def _pre_311_cwd(cls): + # CPython 3.10 pathlib.py:1088 - cls(cls._accessor.getcwd()). + return cls(cls._accessor.getcwd()) + + pathlib.Path.resolve = _pre_311_resolve + pathlib.Path._accessor = _normal_accessor + pathlib.Path.cwd = classmethod(_pre_311_cwd) + print("ACCESSOR_EMULATED") +""" + + +def _seedgo_modules(): + """Every importable module under seedgo/apps, enumerated live. + + Enumerated in the PARENT and injected, so a module added tomorrow is covered + without anyone remembering to list it — and so the child never needs a + directory walk in a world where the filesystem is being denied. + """ + modules = [] + for path in sorted(SEEDGO_ROOT.glob("apps/**/*.py")): + parts = list(path.relative_to(SEEDGO_ROOT).parts) + parts = parts[:-1] if parts[-1] == "__init__.py" else parts[:-1] + [parts[-1][:-3]] + modules.append("aipass.seedgo." + ".".join(parts) if parts else "aipass.seedgo") + return modules + + +def _run(world: str): + """Run a world in a child process, fed on stdin. + + stdin, not a script file: code read from stdin has co_filename ````, + which the handlers guard skips as a pseudo-file. A real script path on disk + would be a caller OUTSIDE the branch and the guard would refuse the import — + a correct refusal that has nothing to do with what this file measures. + """ + return subprocess.run( + [sys.executable, "-"], + input=world, + capture_output=True, + text=True, + cwd=str(SEEDGO_ROOT.parents[2]), + ) + + +def _import_every_module_body(): + lines = [f"import {name} # noqa: F401" for name in _seedgo_modules()] + return "\n".join(lines) + '\nprint("IMPORTED_ALL")\n' + + +def _defect_body(tmp_path: Path, source: str, name: str) -> str: + """A module in the deleted shape, on disk, imported by absolute path.""" + target = tmp_path / f"{name}.py" + target.write_text(source, encoding="utf-8") + return f""" +import importlib.util + +_spec = importlib.util.spec_from_file_location({name!r}, {str(target)!r}) +_mod = importlib.util.module_from_spec(_spec) +try: + _spec.loader.exec_module(_mod) + print("DEFECT_SURVIVED") +except OSError as exc: + print("DEFECT_DIED:", type(exc).__name__) +""" + + +@pytest.fixture(scope="module") +def world_a_result(): + """The module sweep rides WORLD A-PRIME, not world A. + + The claim being measured is "every seedgo module imports with an unreadable + cwd", and on 3.10 world A does not arm — so the sweep would have passed + there against a world that denied nothing. Vacuously green on exactly one + interpreter, and nothing in the output would have said so. + """ + return _run(PRELOAD + ARM_WORLD_A_PRIME + PROBE + _import_every_module_body()) + + +@pytest.fixture(scope="module") +def world_a_result_on_310_shaped_pathlib(): + """The same sweep on the interpreter shape this machine does not have. + + Closes a mutant that survived the first run: swapping the sweep back to the + faithful world A changed nothing on 3.12, because there both worlds arm. It + is only on 3.10 that the choice decides whether the sweep measures anything + at all — so the 3.10 shape is emulated and the sweep run against it, and the + mutant now dies here. + """ + return _run(PRELOAD + EMULATE_PY310_PATHLIB + ARM_WORLD_A_PRIME + PROBE + _import_every_module_body()) + + +@pytest.fixture(scope="module") +def world_b_result(): + return _run(PRELOAD + ARM_WORLD_B + _import_every_module_body()) + + +#: The three answers the nt arming probe can honestly give, kept as a plain +#: judgement over measured values so every row is reachable on any host +#: (@commons' judgement/world separation, round 5). +ALIAS_DISCRIMINATION_LIVE = "ARMED_AND_DISCRIMINATING" +ALIAS_DISCRIMINATION_UNFALSIFIABLE = "ARMED_BUT_INDISTINGUISHABLE_FROM_AN_ALIAS" +EMULATION_NOT_ARMED = "NOT_ARMED" + + +def _alias_discrimination_verdict(host_reads_cwd: bool, emulated_reads_cwd: bool) -> str: + """What the nt arming probe can claim, given what it measured. + + THE ROUND-8 RED IS THE SECOND ROW. The probe asserted the HOST does not read + the cwd for an absolute path — a posixpath fact, written as a portable + baseline, inside the pin built to catch exactly that species. Windows ran it + for real and answered True: on nt, ``os.path.realpath`` IS ntpath's, which + reads ``os.getcwd()`` unconditionally (ntpath.py:678). + + On such a host the emulation cannot be told apart from @flow's M3 alias trap, + because an alias and the by-property emulation produce the same answer there. + That row is genuinely unfalsifiable on nt, so it is REPORTED as unfalsifiable + rather than asserted away — @ai_mail's round-5 rule, one platform over: a row + no local host can contradict enters silently, so pin the decision instead of + a guess. + + Args: + host_reads_cwd: Whether the bare host reads the cwd for an ABSOLUTE path. + emulated_reads_cwd: The same measurement under the nt emulation. + + Returns: + One of the three module constants above. + """ + if not emulated_reads_cwd: + return EMULATION_NOT_ARMED + if host_reads_cwd: + return ALIAS_DISCRIMINATION_UNFALSIFIABLE + return ALIAS_DISCRIMINATION_LIVE + + +#: Why the alias trap can or cannot be caught on the interpreter running this. +#: THREE answers, because there turned out to be two different ways of losing it +#: and a verdict that could not tell them apart would hide the more interesting +#: one (@trigger's round-8 line: a kill by string guard is indistinguishable +#: from a kill by measurement in a summary that only counts). +ALIAS_CATCHABLE = "ALIAS_IS_CATCHABLE_HERE" +ALIAS_LOST_TO_PLATFORM = "ALIAS_INDISTINGUISHABLE:the alias IS this host" +ALIAS_LOST_TO_VERSION = "ALIAS_INDISTINGUISHABLE:ntpath no longer calls a rooted literal absolute" +ALIAS_LOST_TO_BEHAVIOUR = "ALIAS_INDISTINGUISHABLE:a foreign ntpath read the cwd anyway" + + +CONTROL_EXPECTS_DEATH = "WORLD_A_STILL_CONVICTS" +CONTROL_EXPECTS_SURVIVAL = "WORLD_A_IS_DISARMED" + + +def _chimera_control_expectation(reaches_under_chimera: bool, has_captured_accessor: bool) -> str: + """What breaking the ``os.path`` identity should do to world A here. + + Three interpreters answered this differently and each one taught the same + lesson later than the last. Pre-3.11 the captured accessor is a second + route the break cannot touch. On 3.13 the break MOVES the route instead of + severing it, because resolve reads ``os.path`` at call time - so the + world's own patch rides along. Only where neither holds does the control + actually disarm anything. + + Both facts are measured in the same child that runs the control, so this + is a judgement over readings rather than a table keyed on a version or an + operating system - the shape that has been wrong three rounds running. + + Args: + reaches_under_chimera: Whether a patch on ``os.path.realpath`` still + reaches ``resolve`` once ``os.path`` points at ntpath. + has_captured_accessor: Whether pathlib holds a pre-3.11 accessor. + + Returns: + One of the two module constants above. + """ + if reaches_under_chimera or has_captured_accessor: + return CONTROL_EXPECTS_DEATH + return CONTROL_EXPECTS_SURVIVAL + + +ROUTE_VIA_MODULE = "A_MODULE_PATCH_REACHES_RESOLVE" +ROUTE_VIA_ACCESSOR = "A_MODULE_PATCH_IS_NOT_READ_AGAIN:pathlib captured its own copy" +ROUTE_UNEXPLAINED = "NO_ROUTE_EXPLAINS_THIS_HOST" + + +def _module_patch_route(module_patch_reaches_resolve: bool, has_captured_accessor: bool) -> str: + """Whether rebinding ``os.path.realpath`` can reach ``Path.resolve`` here. + + Every arming world in this file rests on this and round 10 wrote the + 3.12-shaped SPELLING of it down as a pin: "Path's routing module IS + os.path". CI answered honestly on 3.10 and 3.11 - there ``_flavour`` is a + ``_PosixFlavour`` OBJECT and the probe reported False - and the pin that + existed to document the assumption had the assumption in its assertion. + + The fact that decides the route is not the flavour's spelling but whether + pathlib holds a CAPTURED COPY. Before 3.11 ``_NormalAccessor`` took + ``realpath = staticmethod(os.path.realpath)`` at class creation and + ``resolve`` called ``self._accessor.realpath`` (pathlib.py:358 and 1077), + so rebinding the module attribute rebinds a name nothing reads again - + which is why ARM_WORLD_A patches the accessor TOO, and why measuring it + here is measuring the thing the worlds actually depend on. + + ROUND 12 TOOK THE PREDICTION OUT OF IT. The round-11 version read the route + off two attribute proxies - accessor present, flavour is os.path - and 3.11 + is the HYBRID neither proxy can express: the captured accessor is gone + (like 3.12+) while the flavour is still an OBJECT (like 3.10), and the + route there is the call-time module read. The table answered + NO_ROUTE_EXPLAINS_THIS_HOST for a shipped interpreter, which is a table + saying "not in my table" and calling it a verdict. + + So the route is now the MEASUREMENT, and the accessor only EXPLAINS a route + that was not taken. UNEXPLAINED means measured-and-genuinely-unexplained: + nothing reached resolve and no captured copy accounts for it. The same + end-of-arms cure the disarm control got in round 11, applied one test over - + @devpulse pointed at exactly that asymmetry. + + Args: + module_patch_reaches_resolve: Whether a spy installed on + ``os.path.realpath`` is actually reached by ``Path.resolve`` here. + has_captured_accessor: Whether ``pathlib._NormalAccessor`` exists - the + only thing in this file that EXPLAINS a module patch failing to + reach. + + Returns: + One of the three module constants above. + """ + if module_patch_reaches_resolve: + return ROUTE_VIA_MODULE + if has_captured_accessor: + return ROUTE_VIA_ACCESSOR + return ROUTE_UNEXPLAINED + + +def _alias_catchability( + alias_reads_cwd: bool, + ntpath_calls_probe_absolute: bool, + alias_is_the_host: bool, +) -> str: + """Whether @flow's M3 alias trap is detectable here, and if not, why not. + + THE TRAP: emulating nt by ``os.path.realpath = ntpath.realpath`` reproduces + nothing on a posix host, because off-Windows ``ntpath.realpath`` is just + ``abspath`` (ntpath.py:564) and an absolute path never reaches ``getcwd``. + The check that catches it is "the alias did NOT read the cwd". + + ROUND 9 FOUND THE SECOND WAY TO LOSE IT, and it is a VERSION fact, not a + platform one. ``_abspath_fallback`` consults ``ntpath.isabs``, and through + 3.12 that carried an explicit LEGACY BUG clause making a rooted driveless + path absolute (ntpath.py:99-102). 3.13 removed it: ``/tmp`` needs a drive or + a UNC prefix to count. So on 3.13 the alias DOES read the cwd for the probe + path, and answers exactly as the by-property emulation does. + + Which is my own checker-pack sentence landing in my own file: the platform + dimension IS the version dimension. The probe was keyed on a fact only one + leg of the matrix could contradict, and the leg that contradicted it was an + interpreter, not an operating system. + + ROUND 10 SPLIT THE PLATFORM ARM FROM WHAT IT WAS INFERRED FROM. The two + argument version read the platform off ``ntpath_calls_probe_absolute``, + which is a proxy and not the thing: a verdict that SAYS "the alias IS this + host" while never having measured identity claims a fact it does not hold. + A sweep of this file under a 3.13-shaped ntpath convicted it - on a real + 3.13 leg the two dimensions collide and the message names the wrong one. + + So the platform arm now rests on ``os.path is ntpath``, measured, and the + fourth verdict exists for the case the old ordering swallowed: a FOREIGN + ntpath that reads the cwd anyway. That is what an nt realpath does and what + this file's own nt emulation installs, and calling it "the alias IS this + host" would have been an emulation reported as a platform. + + Args: + alias_reads_cwd: Whether an ntpath-aliased realpath reads the cwd for + the probe path on this host. + ntpath_calls_probe_absolute: What ``ntpath.isabs`` says about that same + path - the version dimension, so the verdict names a cause. + alias_is_the_host: Whether ``os.path`` already IS ``ntpath`` - the + platform dimension, and the only fact that supports the word + "host" in a verdict. + + Returns: + One of the four module constants above. + """ + if not alias_reads_cwd: + return ALIAS_CATCHABLE + if alias_is_the_host: + return ALIAS_LOST_TO_PLATFORM + if not ntpath_calls_probe_absolute: + return ALIAS_LOST_TO_VERSION + return ALIAS_LOST_TO_BEHAVIOUR + + +class TestTheInstrumentsCanFire: + """Positive controls: each world must still kill the code the cure deleted.""" + + def test_world_a_kills_a_module_level_resolve(self, tmp_path): + """Unconditional again, on every interpreter, now that the world patches + the captured accessor as well as the module attribute.""" + body = _defect_body(tmp_path, DEFECT_A_SOURCE, "defect_a") + result = _run(PRELOAD + ARM_WORLD_A + body) + assert "DEFECT_DIED" in result.stdout, f"world A is not armed: {result.stdout}\n{result.stderr}" + + def test_world_a_prime_kills_a_module_level_resolve_on_every_version(self, tmp_path): + """The second construction, kept as a cross-check rather than as a + stand-in: it patches ``Path.resolve`` itself, so it cannot be fooled by + anything pathlib does internally on any version.""" + body = _defect_body(tmp_path, DEFECT_A_SOURCE, "defect_a") + result = _run(PRELOAD + ARM_WORLD_A_PRIME + body) + assert "DEFECT_DIED" in result.stdout, f"world A-prime is not armed: {result.stdout}\n{result.stderr}" + + def test_the_two_world_a_constructions_agree(self, tmp_path): + """@memory's licence, ratified round 3: two ways of building one world + must agree, so a divergence surfaces as a red rather than as one + instrument quietly becoming the only one.""" + body = _defect_body(tmp_path, DEFECT_A_SOURCE, "defect_a") + faithful = _run(PRELOAD + ARM_WORLD_A + body) + cross_check = _run(PRELOAD + ARM_WORLD_A_PRIME + body) + assert "DEFECT_DIED" in faithful.stdout, faithful.stdout + assert "DEFECT_DIED" in cross_check.stdout, cross_check.stdout + + def test_a_getcwd_denial_does_NOT_reach_a_pre_captured_getcwd_accessor(self, tmp_path): + """The realpath control's sibling, on the route nothing here was driving. + + Before 3.11 ``Path.cwd()`` is ``cls(cls._accessor.getcwd())`` — a + SEPARATE captured attribute from ``_accessor.realpath``. Rebinding + ``os.getcwd`` alone therefore cannot be seen from it, exactly as + rebinding ``os.path.realpath`` could not be seen from the other half. + + Found by @skills, who hit it as a live mutant survivor: deleting their + world's realpath patch changed nothing, because every pin underneath was + riding the getcwd half. Arming one route says nothing about the other. + """ + body = _defect_body(tmp_path, DEFECT_C_SOURCE, "defect_c") + result = _run(PRELOAD + EMULATE_PY310_PATHLIB + BARE_MODULE_PATCH_ONLY + body) + assert "DEFECT_SURVIVED" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_world_a_convicts_the_getcwd_route_TOO(self, tmp_path): + """And the cured world reaches it. Without the second staticmethod line + this pin is red — which is the whole reason it exists, because the tree + has seven Path.cwd() sites today and none of them is reached at import. + The day one is, the world must already have been able to convict it.""" + body = _defect_body(tmp_path, DEFECT_C_SOURCE, "defect_c") + result = _run(PRELOAD + EMULATE_PY310_PATHLIB + ARM_WORLD_A + body) + assert "DEFECT_DIED" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_the_emulation_is_INERT_on_a_host_that_already_has_an_accessor(self, tmp_path): + """The round-8 3.10 red, held closed on this interpreter forever. + + On CI's real 3.10 the host already had the pre-3.11 shape, and the + emulation replaced a fully-featured accessor with a two-method stub - + so pathlib lost the methods it routes its own API through and every + seedgo module import died on `AttributeError: '_NormalAccessor' object + has no attribute 'mkdir'`. Red for the instrument, not the defect. + """ + probe = ( + "import pathlib, tempfile\n" + "target = pathlib.Path(tempfile.mkdtemp()) / 'sub'\n" + "target.mkdir()\n" + "print('MKDIR_WORKED')\n" + ) + world = EMULATE_REAL_310_ACCESSOR_HOST + EMULATE_NATIVE_ACCESSOR_HOST + result = _run(PRELOAD + world + EMULATE_PY310_PATHLIB + probe) + assert "NATIVE_HOST_ALREADY" in result.stdout, f"{result.stdout}\n{result.stderr}" + assert "ACCESSOR_NATIVE" in result.stdout, f"{result.stdout}\n{result.stderr}" + assert "MKDIR_WORKED" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_the_emulation_still_TAKES_on_a_host_without_one(self, tmp_path): + """The other half. An emulation that had learned to do nothing would + satisfy the pin above and quietly take every 3.10 claim in this file + dark - which is the arming-probe defect wearing a fix's clothes. + + THE ABSENCE IS EMULATED rather than assumed. Round 9's red: this pin + asserted ACCESSOR_EMULATED unconditionally, which is only true on a host + that has no accessor - a 3.12 fact. On the real 3.10 leg the emulation + correctly reported ACCESSOR_NATIVE and the pin died for being right. + A pin that expects one arm must BUILD the host that produces it, or it + is testing whichever arm its own interpreter happens to have. + """ + probe = "print('BODY_RAN')\n" + result = _run(PRELOAD + REMOVE_ANY_NATIVE_ACCESSOR + EMULATE_PY310_PATHLIB + probe) + assert "ACCESSOR_EMULATED" in result.stdout, f"{result.stdout}\n{result.stderr}" + assert "BODY_RAN" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_the_native_host_emulation_ROUTES_and_does_not_merely_declare(self, tmp_path): + """Arming probe for the reproduction itself. + + My first version of this world only SET pathlib._NormalAccessor. Both + arms came back green, because on 3.12 nothing reads it - the world + declared the shape without having it. A host that carries the attribute + and does not route through it cannot reproduce the red, and an emulation + that quietly agrees with its host is indistinguishable from a cure. + """ + probe = ( + "import pathlib, tempfile\n" + "seen = []\n" + "_real = pathlib.Path._accessor.mkdir\n" + "pathlib.Path._accessor.mkdir = lambda *a, **k: (seen.append(1), _real(*a, **k))[1]\n" + "(pathlib.Path(tempfile.mkdtemp()) / 'sub').mkdir()\n" + "print('MKDIR_WENT_THROUGH_ACCESSOR:', bool(seen))\n" + ) + result = _run(PRELOAD + EMULATE_NATIVE_ACCESSOR_HOST + probe) + assert "MKDIR_WENT_THROUGH_ACCESSOR: True" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_the_native_world_does_not_DECAPITATE_a_real_pre_311_host(self, tmp_path): + """The round-9 3.10 red, held closed here forever. + + Round 8 cured EMULATE_PY310_PATHLIB of replacing a real accessor, and I + then built its reproduction partner with the identical flaw: a + three-method stand-in installed over CPython 3.10's real _NormalAccessor, + which routes stat through it. On 3.12 nothing reads the attribute so both + arms looked right; on the real 3.10 leg every pin riding this world died + on `AttributeError: '_NativeAccessor' object has no attribute 'stat'`. + + The same defect, one file over, in the world built to reproduce it. + """ + probe = "import pathlib, tempfile\nd = pathlib.Path(tempfile.mkdtemp())\nprint('IS_DIR:', d.is_dir())\n" + world = EMULATE_REAL_310_ACCESSOR_HOST + EMULATE_NATIVE_ACCESSOR_HOST + result = _run(PRELOAD + world + probe) + assert "NATIVE_HOST_ALREADY" in result.stdout, f"{result.stdout}\n{result.stderr}" + assert "IS_DIR: True" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_the_SYNTHESISED_accessor_delegates_what_it_does_not_name(self, tmp_path): + """Belt to the braces above, and the half a no-op guard cannot cover. + + The guard stops the world installing over a real accessor. It does not + stop the synthetic one being incomplete on a host that genuinely has + none - so the stand-in delegates every unnamed attribute to the os + function of the same name. An accessor is a namespace, and a stand-in + for a namespace that answers three questions is a trap for the fourth. + """ + probe = ( + "import pathlib\n" + "acc = pathlib._normal_accessor\n" + "print('DELEGATES_STAT:', callable(getattr(acc, 'stat', None)))\n" + "print('DELEGATES_LISTDIR:', callable(getattr(acc, 'listdir', None)))\n" + ) + result = _run(PRELOAD + REMOVE_ANY_NATIVE_ACCESSOR + EMULATE_NATIVE_ACCESSOR_HOST + probe) + assert "NATIVE_HOST_SYNTHESISED" in result.stdout, f"{result.stdout}\n{result.stderr}" + assert "DELEGATES_STAT: True" in result.stdout, f"{result.stdout}\n{result.stderr}" + assert "DELEGATES_LISTDIR: True" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_the_real_310_host_ROUTES_stat_and_does_not_merely_declare_it(self, tmp_path): + """Arming probe for the round-9 reproduction, for the same reason its + round-8 sibling needed one: a host that carries the attribute without + routing through it reproduces nothing, and the pin above would pass + against a world that never had the shape.""" + probe = ( + "import pathlib, tempfile\n" + "seen = []\n" + "_real = pathlib.Path._accessor.stat\n" + "pathlib.Path._accessor.stat = lambda *a, **k: (seen.append(1), _real(*a, **k))[1]\n" + "pathlib.Path(tempfile.mkdtemp()).is_dir()\n" + "print('STAT_WENT_THROUGH_ACCESSOR:', bool(seen))\n" + ) + result = _run(PRELOAD + EMULATE_REAL_310_ACCESSOR_HOST + probe) + assert "STAT_WENT_THROUGH_ACCESSOR: True" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_the_accessor_absence_emulation_actually_REMOVES_it(self, tmp_path): + """Arming probe for REMOVE_ANY_NATIVE_ACCESSOR. On 3.11+ it is a no-op + by construction, so without this pin the world could stop working and + every arm expectation built on it would keep passing here while going + dark on exactly the leg it exists for.""" + probe = "import pathlib\nprint('HAS_ACCESSOR:', hasattr(pathlib, '_NormalAccessor'))\n" + before = _run(PRELOAD + EMULATE_REAL_310_ACCESSOR_HOST + probe) + after = _run(PRELOAD + EMULATE_REAL_310_ACCESSOR_HOST + REMOVE_ANY_NATIVE_ACCESSOR + probe) + assert "HAS_ACCESSOR: True" in before.stdout, f"{before.stdout}\n{before.stderr}" + assert "HAS_ACCESSOR: False" in after.stdout, f"{after.stdout}\n{after.stderr}" + + def test_the_native_host_mkdir_honours_parents_and_exist_ok(self, tmp_path): + """The fidelity of the stand-in, pinned rather than left to run order. + + Without this the gap is only visible when a directory already exists - + which in a full suite it does and in a single-test run it does not. The + pin above went red in the suite and green alone, and a mutant deleting + the exist_ok arm still survived a two-file selection. An emulation is + only as honest as the behaviour it keeps. + """ + probe = ( + "import pathlib, tempfile\n" + "root = pathlib.Path(tempfile.mkdtemp()) / 'a' / 'b'\n" + "root.mkdir(parents=True, exist_ok=True)\n" + "root.mkdir(parents=True, exist_ok=True)\n" + "print('PARENTS_AND_EXIST_OK_HONOURED')\n" + ) + result = _run(PRELOAD + EMULATE_NATIVE_ACCESSOR_HOST + probe) + assert "PARENTS_AND_EXIST_OK_HONOURED" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_every_module_imports_on_a_NATIVE_accessor_host_too(self, tmp_path): + """The failing CI pin's own shape, run under the host that failed it. + + This is the pin the round-8 red actually killed - restated so that the + 3.10 leg's world is reachable from here. If the emulation ever goes back + to replacing a native accessor, this reds on 3.12 rather than waiting + for a CI leg nobody runs locally. + """ + modules = _seedgo_modules() + assert modules, "no seedgo modules enumerated - this pin would be vacuous" + body = ( + "import importlib\n" + f"for name in {modules[:40]!r}:\n" + " importlib.import_module(name)\n" + "print('ALL_IMPORTED')\n" + ) + world = EMULATE_NATIVE_ACCESSOR_HOST + EMULATE_PY310_PATHLIB + ARM_WORLD_A + result = _run(PRELOAD + world + body) + assert "ACCESSOR_NATIVE" in result.stdout, f"{result.stdout}\n{result.stderr}" + assert "ALL_IMPORTED" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_the_bare_patch_control_holds_on_an_NT_SHAPED_HOST_TOO(self, tmp_path): + """THE LITMUS, and the pin that would have caught the round-6 red here. + + The control above claims a bare module patch cannot reach a captured + accessor. That claim must not depend on which platform is running it — + and until tonight it did: the emulation captured the real + os.path.realpath, which on Windows reads the cwd unconditionally, so the + control raised and reported DEFECT_DIED on the runner while passing + here. Measured on windows-setup, c82c3d34. + + Runs the same world with the nt property emulated. Same verdict, or the + pin above is a posix fact wearing a portable name. + """ + body = _defect_body(tmp_path, DEFECT_A_SOURCE, "defect_a") + world = EMULATE_NT_REALPATH + EMULATE_PY310_PATHLIB + BARE_MODULE_PATCH_ONLY + result = _run(PRELOAD + world + body) + assert "DEFECT_SURVIVED" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_world_a_still_convicts_on_an_NT_SHAPED_HOST(self, tmp_path): + """The other half. A litmus that only showed the control surviving + everywhere would be satisfied by a world that denies nothing — so the + cured world must still ARM under the same emulation, or the pair proves + only that both are inert.""" + body = _defect_body(tmp_path, DEFECT_A_SOURCE, "defect_a") + world = EMULATE_NT_REALPATH + EMULATE_PY310_PATHLIB + ARM_WORLD_A + result = _run(PRELOAD + world + body) + assert "DEFECT_DIED" in result.stdout, f"{result.stdout}\n{result.stderr}" + + ABSOLUTE_CWD_PROBE = ( + "import os\n" + # THE SECOND SITE OF THE ROUND-10 SPECIES, found by sweeping this file + # rather than by reading it. `os.path.abspath(os.sep)` builds the + # argument out of the host being measured, so the row asks a different + # question per platform - the exact defect that took the alias probe + # down on the round-10 board, still living one probe over. + # + # '//x' is absolute under EVERY rule this file can meet: posixpath, + # 3.12 ntpath, and 3.13 ntpath (which reads it as the UNC form). It is + # DELIBERATELY not the alias probe's '/x' - that one has to be the + # rooted driveless shape whose absoluteness 3.13 changed, because + # measuring the version dimension is its whole job. Two literals, two + # jobs; making them agree would take one of the rows dark. + "_abs = '//x'\n" + "print('PROBE_IS_ABS:', os.path.isabs(_abs))\n" + "_seen = []\n" + "_real_getcwd = os.getcwd\n" + "os.getcwd = lambda: (_seen.append(1), _real_getcwd())[1]\n" + "os.path.realpath(_abs)\n" + "print('ABSOLUTE_PATH_READ_CWD:', bool(_seen))\n" + ) + + def _reads_cwd_for_an_absolute_path(self, host: str = "") -> bool: + """Measure it, on whatever host `host` makes this child into.""" + result = _run(PRELOAD + host + self.ABSOLUTE_CWD_PROBE) + assert "PROBE_IS_ABS: True" in result.stdout, f"{result.stdout}\n{result.stderr}" + assert "ABSOLUTE_PATH_READ_CWD:" in result.stdout, f"{result.stdout}\n{result.stderr}" + return "ABSOLUTE_PATH_READ_CWD: True" in result.stdout + + def test_the_nt_emulation_is_armed_and_says_what_it_can_discriminate(self, tmp_path): + """Arming probe for the litmus, keyed on what it MEASURED. + + The version this replaces asserted `ABSOLUTE_PATH_READ_CWD: False` for + the bare host - a posixpath fact written as a portable baseline, in the + pin whose job is to catch that species. Windows ran it for real on the + round-8 board and answered True, because on nt os.path.realpath IS + ntpath's and reads the cwd unconditionally (ntpath.py:678). + + So the baseline is measured rather than assumed, and the probe reports + WHICH claim it can support: on a posix-shaped host the emulation is + distinguishable from @flow's M3 alias trap; on an nt-shaped host it is + not, and saying so is the honest answer rather than a row filled in with + a guess. + """ + host_reads = self._reads_cwd_for_an_absolute_path() + emulated_reads = self._reads_cwd_for_an_absolute_path(EMULATE_NT_REALPATH) + + verdict = _alias_discrimination_verdict(host_reads, emulated_reads) + assert verdict != EMULATION_NOT_ARMED, ( + "the nt emulation did not change an absolute path's cwd-reading behaviour, " + "so every pin riding it measures nothing" + ) + if verdict == ALIAS_DISCRIMINATION_LIVE: + assert host_reads is False + else: + # Recorded, not skipped. The emulated half is still asserted above; + # what cannot be measured here is only whether an ALIAS would have + # produced the same answer - which on this host it would. + assert host_reads is True + + @pytest.mark.parametrize( + "host_reads,emulated_reads,expected", + [ + (False, True, ALIAS_DISCRIMINATION_LIVE), + (True, True, ALIAS_DISCRIMINATION_UNFALSIFIABLE), + (False, False, EMULATION_NOT_ARMED), + (True, False, EMULATION_NOT_ARMED), + ], + ) + def test_the_verdict_table_is_reachable_on_any_host(self, host_reads, emulated_reads, expected): + """Every row runnable here, including the nt one no Linux box can + produce live. A literal table, so it cannot vanish.""" + assert _alias_discrimination_verdict(host_reads, emulated_reads) == expected + + def test_the_probe_answers_TRUE_on_an_nt_SHAPED_HOST_and_the_pin_survives_it(self, tmp_path): + """The round-8 red reproduced on Linux, and held closed. + + Running the bare-host measurement under the nt property is what CI did + for real. The old pin asserted False here and died; this one measures + True, routes to the unfalsifiable verdict, and still asserts the half it + can support. + """ + # ONE application, read twice. Stacking the emulation on itself is not + # "an nt host with the emulation applied" - it is a wrapper capturing a + # module name it then rebinds, which recurses until the stack ends. That + # is @flow's round-7 self-eating emulation in miniature, and it is the + # reason the nt host is modelled as host == emulated rather than as two + # layers: on nt the emulation IS the host's own behaviour, which is + # exactly why the alias row goes unfalsifiable there. + host_reads = self._reads_cwd_for_an_absolute_path(EMULATE_NT_REALPATH) + emulated_reads = host_reads + assert host_reads is True + assert _alias_discrimination_verdict(host_reads, emulated_reads) == (ALIAS_DISCRIMINATION_UNFALSIFIABLE) + + NTPATH_313_ISABS = ( + "import ntpath, os\n" + "\n" + "def _isabs_313(s):\n" + " # CPython 3.13 ntpath.isabs - the LEGACY BUG clause is gone, so a\n" + " # rooted driveless path needs a drive or a UNC prefix to count.\n" + " s = os.fspath(s)\n" + " sep, altsep, colon_sep = chr(92), '/', ':' + chr(92)\n" + " s = s[:3].replace(altsep, sep)\n" + " return s.startswith(colon_sep, 1) or s.startswith(sep * 2)\n" + "\n" + "ntpath.isabs = _isabs_313\n" + ) + + ALIAS_WORLD = "import ntpath, os\nos.path.realpath = ntpath.realpath\n" + + ALIAS_PROBE = ( + "import ntpath, os\n" + # A DIALECT-NEUTRAL LITERAL, written once and never derived from the + # host. `os.path.abspath(os.sep)` was the round-9 spelling and it is a + # different SHAPE per platform: '/' on posix (rooted, driveless - the + # exact shape 3.13's isabs change is about) and 'D:\\' on nt, which is + # DRIVE-rooted and absolute under both isabs rules. So the row asked a + # different question on Windows and answered it correctly. An + # instrument's inputs are behaviour too (@flow, round 8), and a probe + # must not build its argument out of the host it is measuring. + "_abs = '/x'\n" + "print('ALIAS_IS_THE_HOST:', os.path is ntpath)\n" + "print('NTPATH_CALLS_IT_ABSOLUTE:', ntpath.isabs(_abs))\n" + "_seen = []\n" + "_real_getcwd = os.getcwd\n" + "os.getcwd = lambda: (_seen.append(1), _real_getcwd())[1]\n" + "os.path.realpath(_abs)\n" + "print('ALIAS_READ_CWD:', bool(_seen))\n" + ) + + def _measure_alias(self, host: str = "") -> tuple: + """`(alias_reads_cwd, ntpath_calls_probe_absolute, alias_is_the_host)`. + + The third value is the variable that actually DECIDES whether this + check can discriminate at all: where ``os.path`` already IS ``ntpath``, + the alias world is a no-op and no probe can tell an alias from the + real thing. Measured rather than inferred from ``os.name``, because the + question is about module identity and not about a platform label + (@ai_mail's round-5 rule: key the table on what decides). + """ + result = _run(PRELOAD + host + self.ALIAS_WORLD + self.ALIAS_PROBE) + assert "ALIAS_READ_CWD:" in result.stdout, f"{result.stdout}\n{result.stderr}" + assert "ALIAS_IS_THE_HOST:" in result.stdout, f"{result.stdout}\n{result.stderr}" + return ( + "ALIAS_READ_CWD: True" in result.stdout, + "NTPATH_CALLS_IT_ABSOLUTE: True" in result.stdout, + "ALIAS_IS_THE_HOST: True" in result.stdout, + ) + + def test_the_alias_trap_is_caught_or_the_reason_is_named(self, tmp_path): + """@flow's M3 trap, and what this interpreter can actually say about it. + + Measured, then reported. Where the trap is catchable this still catches + it; where it is not, the verdict names WHICH dimension took it away. + """ + alias_reads, ntpath_absolute, alias_is_the_host = self._measure_alias() + verdict = _alias_catchability(alias_reads, ntpath_absolute, alias_is_the_host) + if verdict == ALIAS_CATCHABLE: + assert alias_reads is False + else: + # Not skipped: the loss is recorded with its mechanism attached, so + # a future reader sees a measured boundary rather than a gap. + assert alias_reads is True + + def test_the_catchability_matches_WHAT_THE_HOST_IS(self, tmp_path): + """BOTH ARMS ASSERTED, so this row measures on every operating system. + + The round-9 version asserted ALIAS_CATCHABLE unconditionally and met a + real nt host on the round-10 board. The machinery answered honestly - + ALIAS_INDISTINGUISHABLE: the alias IS this host, the third verdict + firing exactly as designed - and the ASSERTION refused the honest + answer. Written on Linux, where a 3.12-shaped ntpath is foreign and + therefore catchable; shaped like the host it was written on. + + @flow's one-dimension law in its assertion form: a host that already IS + the faked dimension cannot distinguish it. So the expectation is now + derived from the measured identity - is os.path already ntpath - and + both arms are live measurements rather than one arm and one skip. + + WHICH DIMENSION EACH ARM RESTS ON, because the runner is nt AND 3.12 + and only one of those decides this row: the arm below rests on module + IDENTITY (platform), not on the isabs rule (version). The version + dimension is the subject of the next test, and on a host where the + alias is the host it cannot be isolated at all. + """ + alias_reads, ntpath_absolute, alias_is_the_host = self._measure_alias() + verdict = _alias_catchability(alias_reads, ntpath_absolute, alias_is_the_host) + if alias_is_the_host: + assert verdict == ALIAS_LOST_TO_PLATFORM, ( + "os.path IS ntpath here, so aliasing changes nothing and the trap " + f"cannot be caught - expected the platform verdict, measured {verdict}" + ) + elif ntpath_absolute: + assert verdict == ALIAS_CATCHABLE, ( + "os.path is not ntpath here and ntpath calls the probe absolute, so " + "the alias is a foreign shape whose failure to read the cwd is exactly " + f"what catches it - measured {verdict}" + ) + else: + # THE THIRD HOST, and the reason this row has three arms instead of + # two. A 3.13 interpreter on posix is neither of the above: the + # alias is foreign AND it reads the cwd, because ntpath stopped + # calling a rooted driveless literal absolute. Written with two + # arms this test was red on CI's own 3.13 leg - found by sweeping + # the file under a 3.13-shaped ntpath, not by reading it. + assert verdict == ALIAS_LOST_TO_VERSION, ( + "ntpath does not call the probe absolute here, so a foreign alias " + f"reads the cwd for the version reason - measured {verdict}" + ) + + def test_the_alias_is_LOST_TO_VERSION_on_a_313_shaped_ntpath(self, tmp_path): + """The 3.13 row, emulated by property and stated with its dimension. + + THE ROUND-9 VERSION HAD TWO DEFECTS and the Windows leg found both. + It asserted `ntpath_absolute is False`, and it built its probe path as + `os.path.abspath(os.sep)` - which is '/' on posix and 'D:\\' on nt. A + drive-rooted path is absolute under BOTH isabs rules, so on Windows the + emulation was armed and the probe simply asked a different question. + The input, not the world, was host-shaped. + + The literal is dialect-neutral now. What remains genuinely + unmeasurable on an nt host is the ISOLATION: where os.path IS ntpath, + the platform has already made the alias indistinguishable, so no + version emulation can produce a version-only loss. That is stated + rather than skipped past - the cross term devpulse warned about, and + the asymmetry is real: the version row needs a host where the alias is + foreign; the platform row above needs no such thing. + """ + _, bare_ntpath_absolute, _ = self._measure_alias() + alias_reads, ntpath_absolute, alias_is_the_host = self._measure_alias(self.NTPATH_313_ISABS) + if bare_ntpath_absolute: + print("ISABS_313: INSTALLED") + else: + # NATIVE. The interpreter beneath is already 3.13-shaped, so the + # emulation replaced one rule with the same rule and the row below + # is a measurement of the host, not of the emulation. It still + # holds; what it cannot say is that the emulation caused it. + print("ISABS_313: NATIVE") + assert ntpath_absolute is False, ( + "the 3.13 isabs emulation did not take: a rooted driveless literal is still " + "reported absolute, so this row would pass for the wrong reason" + ) + assert alias_reads is True, "the alias did not read the cwd under the 3.13 shape" + verdict = _alias_catchability(alias_reads, ntpath_absolute, alias_is_the_host) + if alias_is_the_host: + # UNVERIFIABLE HERE, BY NAME - and the verdict says so itself now. + # Where os.path IS ntpath the platform has already made the alias + # indistinguishable, so no version emulation can produce a + # version-only loss and the judgement reports the deeper reason. + # The round-10 version asserted LOST_TO_VERSION on both arms, which + # was the same species one turn later: an expectation written on + # the host it was written on. Found by sweeping the file under an + # nt-identity host rather than by reading it. + assert verdict == ALIAS_LOST_TO_PLATFORM, ( + "os.path IS ntpath here, so the version dimension cannot be isolated " + f"and the platform reason outranks it - measured {verdict}" + ) + else: + assert verdict == ALIAS_LOST_TO_VERSION, ( + "on a host where the alias is foreign, a 3.13-shaped isabs is the only " + f"thing that can make it read the cwd - measured {verdict}" + ) + + ROUTE_PROBE = ( + "import os, pathlib\n" + "_p = pathlib.Path('x')\n" + # 3.13 renamed _flavour to parser (pathlib._local). Both names asked + # for, and which one answered is printed, because a probe that finds + # neither must say so rather than report a comfortable False. + "_name = 'NONE'\n" + "_mod = None\n" + "for _attr in ('_flavour', 'parser'):\n" + " _found = getattr(type(_p), _attr, None)\n" + " if _found is not None:\n" + " _name, _mod = _attr, _found\n" + " break\n" + "print('FLAVOUR_ATTR:', _name)\n" + "print('FLAVOUR_IS_OS_PATH:', _mod is os.path)\n" + # Whether the routing attribute is a DIALECT MODULE at all, which is + # what decides if the line above can move when os.path does. Additive: + # every existing row reads its own line and is untouched. + "import ntpath, posixpath\n" + "print('FLAVOUR_IS_A_DIALECT_MODULE:', _mod is posixpath or _mod is ntpath)\n" + "print('HAS_ACCESSOR:', hasattr(pathlib, '_NormalAccessor'))\n" + # THE ROUTE ITSELF, measured rather than inferred from either name + # above. A spy on the module attribute, then one resolve of a literal + # absolute path: if the spy is reached, a module patch is a route here. + "_seen = []\n" + "_real_for_route = os.path.realpath\n" + "def _route_spy(path, *a, **kw):\n" + " _seen.append(1)\n" + " return _real_for_route(path, *a, **kw)\n" + "os.path.realpath = _route_spy\n" + "try:\n" + " pathlib.Path('//x').resolve()\n" + "except OSError as _exc:\n" + # The measurement is whether the spy FIRED, not whether the call + # succeeded - a host that refuses to resolve the literal still answers + # the routing question, and a bare probe would print nothing at all. + " print('ROUTE_RESOLVE_RAISED:', type(_exc).__name__)\n" + "os.path.realpath = _real_for_route\n" + "print('MODULE_PATCH_REACHES_RESOLVE:', bool(_seen))\n" + ) + + def _measure_route_and_report(self, host: str = "") -> tuple: + """`((has_accessor, flavour_is_os_path, reaches), stdout)`. + + The raw stdout comes back because the worlds ANNOUNCE which arm they + took, and a caller that rides a stand-down has to assert against the + arm that actually ran rather than the one it hoped for. + """ + result = _run(PRELOAD + host + self.ROUTE_PROBE) + for line in ("HAS_ACCESSOR:", "FLAVOUR_IS_OS_PATH:", "MODULE_PATCH_REACHES_RESOLVE:"): + assert line in result.stdout, f"{result.stdout}\n{result.stderr}" + return ( + ( + "HAS_ACCESSOR: True" in result.stdout, + "FLAVOUR_IS_OS_PATH: True" in result.stdout, + "MODULE_PATCH_REACHES_RESOLVE: True" in result.stdout, + ), + result.stdout, + ) + + @staticmethod + def _read_line(result, prefix: str) -> str: + """The child's own answer for `prefix`, or a failure naming the child. + + A row that greps for a whole literal line cannot tell "the child said + something else" from "the child never spoke", and the second is a + harness crash wearing the instrument's message - round 8's species. + """ + for line in result.stdout.splitlines(): + if line.startswith(prefix): + return line[len(prefix) :].strip() + raise AssertionError(f"the child never printed {prefix!r}: {result.stdout}\n{result.stderr}") + + def _measure_route(self, host: str = "") -> tuple: + """`(has_accessor, flavour_is_os_path, module_patch_reaches_resolve)`.""" + readings, _ = self._measure_route_and_report(host) + return readings + + def test_the_arming_worlds_patch_the_module_pathlib_ROUTES_THROUGH(self, tmp_path): + """The invariant every ``os.path.realpath`` world here rests on, keyed + on the fact that decides it instead of on a 3.12 spelling. + + THE ROUND-10 VERSION ASSERTED `FLAVOUR_IS_OS_PATH: True` ON EVERY HOST + and CI answered honestly on 3.10, 3.11 and windows: there ``_flavour`` + is a ``_PosixFlavour`` OBJECT, so the probe said False and the pin + written to document the assumption had the assumption inside its own + assertion. @devpulse read it off the board and named it. + + What the worlds actually depend on is whether a module patch REACHES + resolve, so that is what is measured: a spy on os.path.realpath, one + resolve, did the spy fire. Round 11 measured it and then compared it + against a prediction from two attribute proxies; 3.11 is the hybrid + neither proxy can express - accessor gone, flavour still an object, + route present - and the prediction answered "not in my table" for a + shipped interpreter. The measurement is the verdict now, and the + accessor is only asked to EXPLAIN a route that was not taken. + + Both arms run on this interpreter: the bare host reaches, + EMULATE_PY310_PATHLIB does not, and EMULATE_311_HYBRID_HOST presents + the shape that convicted the round-11 table. + """ + has_accessor, flavour_is_os_path, reaches = self._measure_route() + expected = _module_patch_route(reaches, has_accessor) + assert expected != ROUTE_UNEXPLAINED, ( + "nothing reached resolve through os.path and no captured accessor " + "explains why, so every world here that patches os.path.realpath is " + f"inert (flavour is os.path: {flavour_is_os_path})" + ) + # NO SECOND ASSERTION FOR THE MISSED-ROUTE CASE. It read "if the patch + # did not reach, there must be an accessor", which is the UNEXPLAINED + # arm above said twice - a mutant deleting it survived every pin here, + # and a restatement no mutant can kill is decoration that makes the + # file look better covered than it is. + # THE CONTROL FOR THE CONTROL. Without it every value above can be + # hardcoded to its own expected answer and nothing notices - measured + # in round 10: a probe printing a literal True survived every pin here. + # + # It breaks the identity TOWARD the dialect this host is not, because + # the fixed always-ntpath break is a no-op on nt and windows-setup + # convicted the probe for honestly reporting that nothing had changed. + (broken_accessor, broken_is_os_path, _), break_report = self._measure_route_and_report( + BREAK_THE_ROUTING_IDENTITY + ) + assert "IDENTITY_BROKEN_TOWARD:" in break_report, ( + f"the break did not run, so this control measured nothing: {break_report}" + ) + assert broken_is_os_path is False, ( + "the probe reports the routing identity intact on a host where it was " + f"deliberately broken, so it is not reading anything: {break_report}" + ) + # THE REACH HALF IS NOT ASSERTED HERE, and that is a measured decision. + # It reads False on this interpreter and TRUE on 3.13, whose resolve + # follows os.path at call time - the chimera moves that route instead of + # severing it. Demanding False was a 3.12 fact and a sweep under a + # 3.13-shaped resolve convicted it. The reading is still pinned + # host-independently one row down, where the pre-3.11 shape must drive + # it False on any interpreter. + assert broken_accessor is has_accessor, ( + "the chimera changed whether this host has a captured accessor, which it has no business touching" + ) + + def test_the_pre_311_shape_answers_the_OTHER_arm_from_here(self, tmp_path): + """The arm CI runs and this interpreter cannot reach on its own. + + Under the pre-3.11 shape the module patch must STOP reaching resolve - + that is the whole reason ARM_WORLD_A patches the accessor as well, and + it is the answer 3.10 and 3.11 gave the round-10 pin. Measured here so + the judgement above is exercised on both of its arms from 3.12, the + same way the nt-identity world made the platform arm reachable from + Linux. + """ + has_accessor, flavour_is_os_path, reaches = self._measure_route(EMULATE_PY310_PATHLIB) + assert has_accessor is True, "the pre-3.11 emulation did not install an accessor" + assert _module_patch_route(reaches, has_accessor) == ROUTE_VIA_ACCESSOR + assert reaches is False, ( + "a module patch still reaches resolve under the pre-3.11 shape, so the " + "captured-accessor half of every world here is measuring nothing" + ) + + def test_the_identity_break_goes_TOWARD_the_dialect_the_host_is_not(self, tmp_path): + """Round 12's windows red, reproduced on Linux and held closed. + + The fixed break - always point os.path at ntpath - changes nothing on + nt, where os.path already IS ntpath. Windows-setup measured the probe + honestly reporting the identity intact and the control convicted it for + reading the world correctly: the same third reason the disarm control + already carries, one control over. + + Both directions are exercised from here, and NEITHER is spelled. Each + child reports where it STARTED and where it went, and `_break_direction` + says which pairing is correct - so the row reads the same on a posix + box, on a windows runner, and under the nt-identity emulation. The + round-12 shape it replaces asserted `IDENTITY_BROKEN_TOWARD: ntpath` + for the untouched host, which is this box's answer and nothing more. + + Both starting dialects are CONSTRUCTED rather than borrowed: leaving + one child untouched works here and collapses the pair on a windows + runner, where an untouched child and the nt-identity emulation start + the same way and a world that never turns around satisfies both rows. + A third assertion pins that the two arms really are two. + """ + for label, host in ( + ("a child forced to posix identity", _POSIX_HOST_MODULE_IDENTITY), + ("a child forced to nt identity", _NT_HOST_MODULE_IDENTITY), + ): + child = _run(PRELOAD + host + BREAK_THE_ROUTING_IDENTITY + "print('DONE')\n") + started = self._read_line(child, "IDENTITY_STARTED_AS:") + went = self._read_line(child, "IDENTITY_BROKEN_TOWARD:") + assert went == _break_direction(started), ( + f"{label}: started as {started}, so the break had to go toward " + f"{_break_direction(started)} and went toward {went} - a fixed " + f"direction is the no-op windows convicted: {child.stdout}\n{child.stderr}" + ) + # AND THE TWO ARMS MUST BE DIFFERENT ARMS. Both rows above pass for a + # world that never turns around if both children happen to start the + # same way, so the second host is required to start somewhere else. + as_posix = _run(PRELOAD + _POSIX_HOST_MODULE_IDENTITY + BREAK_THE_ROUTING_IDENTITY + "print('DONE')\n") + as_nt = _run(PRELOAD + _NT_HOST_MODULE_IDENTITY + BREAK_THE_ROUTING_IDENTITY + "print('DONE')\n") + assert self._read_line(as_posix, "IDENTITY_STARTED_AS:") != self._read_line(as_nt, "IDENTITY_STARTED_AS:"), ( + "both children started as the same dialect, so the turn-around was " + f"never exercised: {as_posix.stdout}\n{as_nt.stdout}" + ) + + def test_the_break_actually_MOVES_the_identity_from_either_side(self, tmp_path): + """The reading, not just the announcement. + + A world that printed the right direction and changed nothing would pass + the row above. So the flavour-identity reading is measured on both + sides of the break, from a posix-shaped host and from an nt-shaped one. + + What is asserted is the MOVEMENT, not the destination. On this host the + break moves the reading True -> False. On the nt-identity emulation it + moves False -> True, and that is not a failure: that world is the + CHIMERA - os.path already points at ntpath while pathlib's flavour is + still posixpath - so breaking away from ntpath points os.path back AT + the flavour and re-aligns them. A real nt host, whose flavour is ntpath + too, would read True and move to False like this one does. The + emulation cannot express that difference, so the row refuses to spell + either destination and reads the only thing every host agrees on: the + identity is not where it was. + + And where it CANNOT move, it says so. 3.10 and 3.11 carry a + _PosixFlavour object, 3.13 carries no _flavour at all, and on all three + `flavour is os.path` is False before the break and False after it - + the question does not apply, so the row reports the dead reading rather + than convicting the break for a property of the interpreter. + """ + for label, host in ( + ("this host, untouched", ""), + ("the nt-identity emulation", _NT_HOST_MODULE_IDENTITY), + # THE THIRD ARM IS THE STAND-DOWN, and it is here rather than in a + # row of its own because two mutants proved it unreachable + # otherwise: deleting the stand-down, and forcing the probe's + # dialect-module line to True, both survived every pin in the file + # while this box's flavour was a module. 3.10, 3.11 and 3.13 are + # this arm on CI; an object flavour is this arm from here. + # The HYBRID rather than the bare object-flavour world: on 3.12 a + # bare one leaves resolve asking the stand-in for realpath, which + # it rightly refuses, and the probe dies before it can answer. The + # hybrid is the same object flavour with 3.11's call-time route + # under it - the shape CI actually runs. + ("a child with an OBJECT flavour", EMULATE_311_HYBRID_HOST), + ): + (_, intact, _), report = self._measure_route_and_report(host) + (_, broken, _), _ = self._measure_route_and_report(host + BREAK_THE_ROUTING_IDENTITY) + movable = "FLAVOUR_IS_A_DIALECT_MODULE: True" in report + if not _break_can_move_the_identity(movable): + # 3.10, 3.11 and 3.13 all land here. Reported, not asserted: + # the reading is pinned dead in both directions so a world that + # DID move it on such a host would still be caught. + assert intact is False and broken is False, ( + f"{label}: the routing attribute is not a dialect module, so " + f"`flavour is os.path` cannot be True either side of the " + f"break, and it read {intact} then {broken}: {report}" + ) + continue + assert broken is not intact, ( + f"{label}: the break must MOVE the routing identity; it read " + f"{intact} before and {broken} after, i.e. nothing moved: {report}" + ) + + @pytest.mark.skip( + reason="retired by the 2026-09-01 one-fix ruling: this instrument " + "self-check asserts host facts and redded on a different interpreter " + "each board (3.11/3.13 on 5dee751a); the world it checks is still " + "exercised by every test that uses it; owner to rewrite as measurement " + "after PR 750" + ) + def test_the_311_HYBRID_is_explained_and_reproduces_the_round_11_red(self, tmp_path): + """Round 12's red 1, reproduced on this interpreter and held closed. + + The shape: no captured accessor, a flavour that is an OBJECT, and a + route that is the call-time module read. Round 11's table read the two + attributes as proxies and had no row for that combination, so a real + 3.11 came back NO_ROUTE_EXPLAINS_THIS_HOST and the pin refused it. The + (False, False) row was not an error state - it was a shipped + interpreter. + + Both halves are asserted: the readings must BE the hybrid's, and the + judgement must name the module route for them. The old judgement is + what fails here if it ever comes back, because it reached for the + accessor before the measurement. + """ + (has_accessor, flavour_is_os_path, reaches), report = self._measure_route_and_report(EMULATE_311_HYBRID_HOST) + assert "RESOLVE_READS_OS_PATH_AT_CALL_TIME" in report, ( + f"the call-time half of the hybrid did not install: {report}" + ) + assert has_accessor is False, "the hybrid must have NO captured accessor" + assert flavour_is_os_path is False, f"the hybrid's flavour must be an object, not the os.path module: {report}" + assert reaches is True, ( + "a module patch does not reach resolve on the hybrid, so this host is not " + f"the shape 3.11 presents: {report}" + ) + assert _module_patch_route(reaches, has_accessor) == ROUTE_VIA_MODULE, ( + "the hybrid is explained by the module route it demonstrably takes; " + "reading the accessor first is what made a shipped interpreter unexplained" + ) + + def test_UNEXPLAINED_means_measured_and_not_merely_absent_from_the_table(self, tmp_path): + """The other half of round 12's cure, and the reason the verdict kept + its third value. + + UNEXPLAINED must still be reachable, or the judgement has quietly + become a two-value one and a host that genuinely routes through nothing + this file knows about would be reported as fine. Reached here as a + value rather than as a host, because producing a real pathlib that + neither reaches nor holds a captured copy would mean breaking resolve + itself - and an instrument that breaks the thing it measures reports on + its own damage. + """ + assert _module_patch_route(False, False) == ROUTE_UNEXPLAINED + assert _module_patch_route(True, False) == ROUTE_VIA_MODULE + assert _module_patch_route(False, True) == ROUTE_VIA_ACCESSOR + + @pytest.mark.skip( + reason="retired by the 2026-09-01 one-fix ruling: this instrument " + "self-check asserts host facts and redded 3.10 (ACCESSOR_NATIVE) and " + "3.11 (ACCESSOR_EMULATED) on 5dee751a; the world it checks is still " + "exercised by every test that uses it; owner to rewrite as measurement " + "after PR 750" + ) + def test_the_object_flavour_host_reproduces_the_3_10_ANSWER_from_here(self, tmp_path): + """Round 11's red 1, reproduced on this interpreter. + + CI answered `FLAVOUR_ATTR: _flavour / FLAVOUR_IS_OS_PATH: False` on + 3.10, 3.11 and windows-setup, and the round-10 pin called that a + failure. This builds the shape that gives that answer - a flavour + OBJECT over a captured accessor, which is what those interpreters have + - and requires the routing judgement to read it as the accessor route + rather than as a broken host. + + This is the row the literal table calls (True, False): a host no + machine in this fleet runs, now measurable on all of them. + """ + host = EMULATE_PY310_PATHLIB + EMULATE_OBJECT_FLAVOUR_HOST + (has_accessor, flavour_is_os_path, reaches), report = self._measure_route_and_report(host) + assert has_accessor is True, ( + "no captured accessor here, so this host is not the pre-3.11 shape whichever arm the emulation took" + ) + if "OBJECT_FLAVOUR_UNAVAILABLE" in report: + # A pathlib that spells its routing attribute somewhere else (3.13 + # calls it parser) has no _flavour to stand in for, so this row + # cannot build the shape at all. Said out loud with the reason; the + # accessor half above is still a live measurement here. + assert flavour_is_os_path is True, ( + f"no _flavour to replace, yet the routing attribute is not os.path: {report}" + ) + else: + assert flavour_is_os_path is False, ( + "the flavour is still the os.path module here, so this host is not " + f"the shape 3.10 and 3.11 present and the row proves nothing: {report}" + ) + assert _module_patch_route(reaches, has_accessor) == ROUTE_VIA_ACCESSOR + assert reaches is False + + def test_the_object_flavour_world_STANDS_DOWN_on_a_host_that_has_one(self, tmp_path): + """Host == emulated is one layer, checked for the newest world. + + On 3.10 and 3.11 the flavour already IS an object, and installing over + it would remove parse_parts, casefold and make_uri - behaviour this + world is not testing and the interpreter cannot run without. A mutant + deleting the stand-down branch survives every other pin here, because + this interpreter has no object flavour to protect. So one is installed + first, and the world must recognise it and leave it alone. + """ + marker = ( + "import pathlib, posixpath\n" + "class _AlreadyAnObject:\n" + " sep = posixpath.sep\n" + " marked = True\n" + " def __getattr__(self, name):\n" + " return getattr(posixpath, name)\n" + "pathlib.PurePath._flavour = _AlreadyAnObject()\n" + "pathlib.Path._flavour = pathlib.PurePath._flavour\n" + ) + probe = "import pathlib\nprint('MARKER_SURVIVED:', getattr(pathlib.Path._flavour, 'marked', False))\n" + result = _run(PRELOAD + marker + EMULATE_OBJECT_FLAVOUR_HOST + probe) + assert "OBJECT_FLAVOUR_NATIVE" in result.stdout, ( + "the world installed over a flavour that was already an object, which is " + f"what decapitates a real 3.10: {result.stdout}\n{result.stderr}" + ) + assert "MARKER_SURVIVED: True" in result.stdout, ( + f"the host's own flavour was replaced anyway: {result.stdout}\n{result.stderr}" + ) + + def test_the_object_flavour_world_reports_UNAVAILABLE_with_no_flavour_at_all(self, tmp_path): + """The arm 3.13 takes, reachable from an interpreter that has _flavour. + + A pathlib whose routing attribute is spelled somewhere else is not a + host to stand down FROM - there is nothing to stand in for - and the + world has to say which of the two silences it is in. A mutant deleting + this arm survived every pin in the file, because on 3.12 the attribute + is always there: an arm that only fires on a host you do not have needs + its own reachable row, which is round 11's own lesson applied to round + 12's world. + + The probe deliberately constructs no Path: a pathlib with its flavour + removed cannot parse one, and an instrument that breaks what it + measures reports on its own damage. + """ + flavourless = ( + "import pathlib\n" + "for _cls in (pathlib.Path, pathlib.PurePath, pathlib.PurePosixPath, pathlib.PosixPath):\n" + " try:\n" + " del _cls._flavour\n" + " except AttributeError:\n" + " pass\n" + ) + result = _run(PRELOAD + flavourless + EMULATE_OBJECT_FLAVOUR_HOST + "print('CHILD_LIVED')\n") + assert "OBJECT_FLAVOUR_UNAVAILABLE" in result.stdout, ( + "the world did not report the missing flavour, so it either installed " + f"one or stood down for the wrong reason: {result.stdout}\n{result.stderr}" + ) + assert "CHILD_LIVED" in result.stdout, ( + f"the world took the child down instead of reporting: {result.stdout}\n{result.stderr}" + ) + + @pytest.mark.skip( + reason="retired by the 2026-09-01 one-fix ruling: this instrument " + "self-check asserts host facts and redded 3.10 (ACCESSOR_NATIVE) and " + "3.13 (ACCESSOR_EMULATED) on 5dee751a; the world it checks is still " + "exercised by every test that uses it; owner to rewrite as measurement " + "after PR 750" + ) + def test_the_hybrid_is_the_hybrid_even_where_an_accessor_EXISTS(self, tmp_path): + """Why the hybrid host removes the accessor rather than assuming none. + + On 3.10 - or in any child where something else installed one first - + the other two halves cannot produce the hybrid, and the row asserting + "no accessor" would be red on the interpreter it speaks about. A mutant + dropping the removal survived every pin until this row existed, for the + same reason as the one above: this interpreter has no accessor to + remove. + """ + with_an_accessor = EMULATE_PY310_PATHLIB + EMULATE_311_HYBRID_HOST + (has_accessor, flavour_is_os_path, reaches), report = self._measure_route_and_report(with_an_accessor) + assert "ACCESSOR_EMULATED" in report, f"the accessor was never installed, so this row proves nothing: {report}" + assert has_accessor is False, f"the hybrid host left a captured accessor in place: {report}" + assert reaches is True, f"the hybrid host did not restore the module route: {report}" + assert flavour_is_os_path is False, f"the hybrid host has no object flavour: {report}" + + def test_the_stand_down_reads_the_CONCRETE_class_not_PurePath(self, tmp_path): + """Round 12's red 2, as the exact shape that defeated the guard. + + 3.10 and 3.11 do not define _flavour on PurePath - the concrete + PurePosixPath does - so the round-11 stand-down read None from + PurePath, took the else branch and installed over a real + _PosixFlavour. The host's own pathlib then asked the stand-in for + parse_parts, the delegation forwarded to the posixpath module which + never had it, and the child died in _parse_args before printing + anything the row could read. + + The guard existed. It was looking at the wrong object, which from the + outside is indistinguishable from having no guard at all. + """ + concrete_only = ( + "import pathlib, posixpath\n" + "class _ConcreteFlavour:\n" + " sep = posixpath.sep\n" + " marked = True\n" + " def __getattr__(self, name):\n" + " return getattr(posixpath, name)\n" + # ONLY the concrete class, exactly as 3.10 and 3.11 arrange it. + "pathlib.Path._flavour = _ConcreteFlavour()\n" + "try:\n" + " del pathlib.PurePath._flavour\n" + "except AttributeError:\n" + " pass\n" + ) + probe = "import pathlib\nprint('MARKER_SURVIVED:', getattr(pathlib.Path._flavour, 'marked', False))\n" + result = _run(PRELOAD + concrete_only + EMULATE_OBJECT_FLAVOUR_HOST + probe) + assert "OBJECT_FLAVOUR_NATIVE" in result.stdout, ( + "the world installed over a flavour it could only see on the concrete " + f"class - the round-12 red, exactly: {result.stdout}\n{result.stderr}" + ) + assert "MARKER_SURVIVED: True" in result.stdout, ( + f"the host's own flavour was replaced anyway: {result.stdout}\n{result.stderr}" + ) + + def test_the_object_flavour_REFUSES_realpath_like_the_real_one(self, tmp_path): + """The stand-in must not be friendlier than the thing it stands in for. + + 3.10's _PosixFlavour carries parsing and no realpath - resolve reaches + the accessor instead. A stand-in that answered realpath would let a + 3.12 pathlib route around the accessor entirely, and the host would + quietly stop being the shape it claims to be. + """ + probe = ( + "import pathlib\n" + # A pathlib with no _flavour at all is not a failure of this rule - + # it is a host this row cannot ask. Reported by name rather than + # crashing the child on an attribute 3.13 does not have. + "_f = getattr(pathlib.Path, '_flavour', None)\n" + "if _f is None:\n" + " print('NO_FLAVOUR_TO_ASK')\n" + "else:\n" + " try:\n" + " _f.realpath\n" + " print('FLAVOUR_ANSWERS_REALPATH: True')\n" + " except AttributeError:\n" + " print('FLAVOUR_ANSWERS_REALPATH: False')\n" + " print('FLAVOUR_STILL_PARSES:', _f.sep)\n" + ) + result = _run(PRELOAD + EMULATE_PY310_PATHLIB + EMULATE_OBJECT_FLAVOUR_HOST + probe) + if "NO_FLAVOUR_TO_ASK" in result.stdout: + assert "OBJECT_FLAVOUR_UNAVAILABLE" in result.stdout, ( + f"the child found no flavour but the world claims it installed one: {result.stdout}\n{result.stderr}" + ) + return + assert "FLAVOUR_ANSWERS_REALPATH: False" in result.stdout, ( + f"the stand-in answers a question the original refuses: {result.stdout}\n{result.stderr}" + ) + assert "FLAVOUR_STILL_PARSES: /" in result.stdout, ( + "the stand-in stopped parsing, so it is not a flavour at all - a stand-in " + f"for a namespace must still answer what it is not being tested on: {result.stdout}" + ) + + @pytest.mark.parametrize( + "reaches,has_accessor,expected", + [ + (True, False, ROUTE_VIA_MODULE), + (True, True, ROUTE_VIA_MODULE), + (False, True, ROUTE_VIA_ACCESSOR), + (False, False, ROUTE_UNEXPLAINED), + ], + ) + def test_the_route_table_is_reachable_on_any_host(self, reaches, has_accessor, expected): + """All four combinations over a plain function. + + The rows that pay for themselves are the first two: a reached route is + a reached route whatever else the host carries. The round-11 table read + the accessor FIRST and so answered VIA_ACCESSOR for a host where the + module patch demonstrably arrives - and the (False, False) row, which + used to be 3.11, now means what it says: nothing reached and nothing + here explains it. + """ + assert _module_patch_route(reaches, has_accessor) == expected + + def test_no_probe_DERIVES_its_path_from_the_host_it_measures(self, tmp_path): + """The round-10 species as a property of the source, because on this + host the defect is invisible in behaviour. + + Reverting either probe to `os.path.abspath(os.sep)` changes NOTHING a + posix 3.12 run can see - abspath('/') is '/' and it is absolute - so + the mutant survives every behavioural pin in this file and reappears as + a red on an operating system nobody here runs. It is structurally + detectable, so it is checked structurally: the argument must be a + literal written down once, never computed from the module under + measurement. + """ + for name, probe in ( + ("ABSOLUTE_CWD_PROBE", self.ABSOLUTE_CWD_PROBE), + ("ALIAS_PROBE", self.ALIAS_PROBE), + ): + # The constant IS the child's source text, so this reads the line the + # child will actually run rather than this file's spelling of it. + assignment = [line for line in probe.splitlines() if line.strip().startswith("_abs =")] + assert len(assignment) == 1, f"{name} no longer assigns its probe path once" + assert "os.sep" not in assignment[0], ( + f"{name} builds its path out of the host's separator, so the row asks a different question per platform" + ) + assert "abspath" not in assignment[0], f"{name} builds its path with the path module it is measuring" + + def test_the_sys_modules_half_survives_a_from_import(self, tmp_path): + """What the second line of the identity half is FOR. + + `os.path = ntpath` satisfies every `os.path is ntpath` probe on its + own - a mutant deleting the sys.modules line survived the whole file. + It is not decoration: a child that says `from os.path import isabs` + goes through sys.modules, not through the attribute, and without the + second line it silently gets posixpath back while every identity check + still reads True. Pinned rather than deleted, because the emulation is + for arbitrary child code and not only for this file's probes. + """ + probe = "import ntpath\nfrom os.path import isabs\nprint('FROM_IMPORT_IS_NTPATH:', isabs is ntpath.isabs)\n" + result = _run(PRELOAD + _NT_HOST_MODULE_IDENTITY + probe) + assert "FROM_IMPORT_IS_NTPATH: True" in result.stdout, ( + f"the sys.modules half is not taking: {result.stdout}\n{result.stderr}" + ) + + def test_the_litter_detector_convicts_and_acquits(self, tmp_path): + """Both verdicts of the working-tree detector, reachable without + minting anything - the judgement is a plain function over two sets, so + the convicting row does not need a world that misbehaves.""" + before = {"README.md", "src"} + littered = chr(92) + "tmp" + chr(92) + "tmpx" + assert _litter(before, set(before)) == set() + assert _litter(before, before | {littered}) == {littered} + assert _litter(before, before - {"src"}) == set(), ( + "the detector must not convict on a REMOVED entry - a concurrent " + "citizen tidying its own file is not this file's litter" + ) + + def test_the_detector_reads_the_directory_the_children_actually_use(self, tmp_path): + """The pairing that makes the fixture mean anything: the directory it + watches must be the one `_run` hands the children as their cwd. Pinned + because the two are written down in different places, and a detector + watching an empty room is green forever.""" + probe = "import os\nprint('CHILD_CWD:', os.getcwd())\n" + result = _run(PRELOAD + probe) + assert f"CHILD_CWD: {CHILD_CWD}" in result.stdout, ( + f"the detector watches {CHILD_CWD} but the children run elsewhere: {result.stdout}\n{result.stderr}" + ) + + def test_the_detector_reads_a_real_directory_and_survives_a_missing_one(self, tmp_path): + """The reader half, both branches. An unreadable root answers empty + rather than raising, because a detector that dies on its own snapshot + takes every test in the module with it - and it must not answer empty + for a directory that IS there, which is the failure that would make the + fixture green forever.""" + (tmp_path / "here").mkdir() + assert _working_tree_entries(tmp_path) == {"here"} + assert _working_tree_entries(tmp_path / "absent") == set() + + def test_the_two_probe_literals_answer_DIFFERENT_questions(self, tmp_path): + """Why this file carries two absolute literals instead of one. + + A later reader will see '/x' and '//x' a few hundred lines apart and be + tempted to make them agree. They must not: '//x' is absolute under + every rule here, which is what the cwd-reading probe needs so its + arming assertion means the same thing everywhere; '/x' is absolute + under 3.12's ntpath and NOT under 3.13's, which is the only reason the + version dimension is measurable at all. + + Pinned as a property of the strings, so unifying them is a red. + """ + neutral = self._probe_literal(self.ABSOLUTE_CWD_PROBE) + versioned = self._probe_literal(self.ALIAS_PROBE) + probe = ( + "import ntpath, os, posixpath\n" + "def _isabs_313(s):\n" + " s = os.fspath(s)\n" + " sep, altsep, colon_sep = chr(92), '/', ':' + chr(92)\n" + " s = s[:3].replace(altsep, sep)\n" + " return s.startswith(colon_sep, 1) or s.startswith(sep * 2)\n" + f"for _name, _lit in (('NEUTRAL', {neutral!r}), ('VERSIONED', {versioned!r})):\n" + " print(_name, posixpath.isabs(_lit), _isabs_313(_lit))\n" + ) + result = _run(PRELOAD + probe) + assert "NEUTRAL True True" in result.stdout, f"{result.stdout}\n{result.stderr}" + assert "VERSIONED True False" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_breaking_the_route_DISARMS_world_a_where_the_route_is_the_only_one(self, tmp_path): + """The negative control, keyed on the route the host actually has. + + THE ROUND-10 VERSION DEMANDED `DEFECT_SURVIVED` EVERYWHERE and CI + answered `DEFECT_DIED: FileNotFoundError` on every leg. @devpulse read + universal-on-CI plus green-locally as machine-shaped and asked me to + find what the child reads that a fresh checkout lacks. MEASURED, AND + THAT IS NOT IT: the child reads nothing machine-local (reproduced with + the registry marker out of reach - still SURVIVED), and the pre-3.11 + shape reproduces the CI answer here exactly. It is VERSION-shaped, with + a platform term riding along. + + The chimera only disarms world A where the module patch is the ONLY + route. Where pathlib holds a captured accessor, ARM_WORLD_A's second + patch keeps convicting and the control is measuring the wrong world; + where os.path IS ntpath already, the chimera is a no-op. Three arms, + each asserted, each reachable from this interpreter. + """ + body = _defect_body(tmp_path, DEFECT_A_SOURCE, "defect_a") + chimera = "import ntpath, os, sys\nos.path = ntpath\nsys.modules['os.path'] = ntpath\n" + has_accessor, _, _ = self._measure_route() + # THE FACT THAT DECIDES IT, MEASURED IN THE SAME WORLD THE CONTROL RUNS. + # The first version of this row predicted the answer from a version and + # a platform - accessor present, os.path already ntpath - and 3.13 + # refuted it: no accessor, not nt, and world A still convicted. So ask + # the child instead of the table: with the chimera installed, does a + # patch on os.path.realpath STILL reach resolve? Where it does, the + # break moved the route rather than severing it, and world A must still + # convict on every such host - including ones nobody here can run. + _, _, reaches_under_chimera = self._measure_route(chimera) + + armed = _run(PRELOAD + ARM_WORLD_A + body) + disarmed = _run(PRELOAD + chimera + ARM_WORLD_A + body) + assert "DEFECT_DIED" in armed.stdout, f"{armed.stdout}\n{armed.stderr}" + + expectation = _chimera_control_expectation(reaches_under_chimera, has_accessor) + if expectation == CONTROL_EXPECTS_DEATH: + # THE ROUTE MOVED OR THERE ARE TWO OF THEM. Either resolve reads + # os.path at call time (3.13, and nt seen from nt), or a captured + # accessor carries a second route the break cannot touch (pre-3.11). + assert "DEFECT_DIED" in disarmed.stdout, ( + "a route to realpath survived the chimera, so world A should still " + f"convict and it did not: {disarmed.stdout}" + ) + else: + assert "DEFECT_SURVIVED" in disarmed.stdout, ( + "breaking the os.path route no longer disarms world A on a host where " + f"it is the only route, so the invariant has stopped being why it works: {disarmed.stdout}" + ) + + @pytest.mark.parametrize( + "reaches,has_accessor,expected", + [ + (False, False, CONTROL_EXPECTS_SURVIVAL), + (True, False, CONTROL_EXPECTS_DEATH), + (False, True, CONTROL_EXPECTS_DEATH), + (True, True, CONTROL_EXPECTS_DEATH), + ], + ) + def test_the_chimera_control_table_is_reachable_on_any_host(self, reaches, has_accessor, expected): + """All four combinations over a plain function. + + The row that pays for itself is (True, False): no accessor and the + route still reachable, which is 3.13 and which no interpreter on this + machine can produce. A mutant deleting that arm from the control + SURVIVED every behavioural pin here before this table existed - the + branch was simply unreachable on 3.12. + """ + assert _chimera_control_expectation(reaches, has_accessor) == expected + + def test_the_313_shape_reproduces_the_UNEXPLAINED_leg_from_here(self, tmp_path): + """Round 11's leftover: the leg the captured-accessor mechanism did not + explain, reproduced on this interpreter. + + THE EVIDENCE (@devpulse, addendum): the real 3.13 runner answered + DEFECT_DIED: FileNotFoundError, verbatim identical to the 3.10 leg - + and 3.13 has no _NormalAccessor and no _flavour, so nothing this file + had named could be the reason. My round-11 control predicted SURVIVED + there from a version-and-platform table and would have gone red again. + + The shape that produces that answer is a resolve reading ``os.path`` AT + CALL TIME: the chimera then MOVES the route instead of severing it, and + the world's own patch rides along. Built and measured here rather than + quoted from a source line this machine cannot read - what is asserted + is that the shape reproduces the reported answer, and that the route + measurement explains it in the same child. + """ + body = _defect_body(tmp_path, DEFECT_A_SOURCE, "defect_a") + chimera = "import ntpath, os, sys\nos.path = ntpath\nsys.modules['os.path'] = ntpath\n" + world = EMULATE_313_RESOLVE_READS_OS_PATH + chimera + disarmed = _run(PRELOAD + world + ARM_WORLD_A + body) + _, _, reaches_under_chimera = self._measure_route(world) + + assert "RESOLVE_READS_OS_PATH_AT_CALL_TIME" in disarmed.stdout, ( + f"the 3.13 shape did not install, so this row measured nothing: {disarmed.stdout}" + ) + assert reaches_under_chimera is True, ( + "the 3.13 shape did not move the route, so it is not the shape that explains the reported answer" + ) + assert "DEFECT_DIED: FileNotFoundError" in disarmed.stdout, ( + f"the 3.13 shape no longer reproduces the leg it was built from: {disarmed.stdout}\n{disarmed.stderr}" + ) + + def test_the_313_shape_is_what_MOVES_the_route_and_not_the_chimera(self, tmp_path): + """The control that separates the two halves of the row above. + + Without it, a world that broke nothing and a chimera that severed + nothing would look the same from downstream: both end in DEFECT_DIED. + So the same chimera is measured with and without the 3.13 shape, and + the route reading must MOVE - False on this interpreter's own resolve, + True once resolve reads os.path at call time. + """ + chimera = "import ntpath, os, sys\nos.path = ntpath\nsys.modules['os.path'] = ntpath\n" + _, _, without = self._measure_route(chimera) + _, _, with_313 = self._measure_route(EMULATE_313_RESOLVE_READS_OS_PATH + chimera) + assert with_313 is True + if without: + # NATIVE. This interpreter's own resolve already follows os.path - + # a real 3.13 does - so the world adds nothing here and cannot be + # shown to be what moves the route. Reported rather than asserted + # away: the row above still holds, it just is not this host that + # proves the world caused it. + assert with_313 is without + else: + assert without is False, "the 3.13 shape adds nothing on this host, so the row above proves nothing" + + def test_the_pre_311_shape_reproduces_the_CI_answer_from_here(self, tmp_path): + """Red 2 of round 11, reproduced on this interpreter and held closed. + + The board said `DEFECT_DIED: FileNotFoundError` where the pin demanded + SURVIVED. Under EMULATE_PY310_PATHLIB the same child gives the same + answer here, which is what turns a CI red into a local measurement - + and it is asserted the other way round on the bare host in the same + row, so a change that makes both arms agree is a red rather than a + quieter green. + """ + body = _defect_body(tmp_path, DEFECT_A_SOURCE, "defect_a") + chimera = "import ntpath, os, sys\nos.path = ntpath\nsys.modules['os.path'] = ntpath\n" + pre_311 = _run(PRELOAD + EMULATE_PY310_PATHLIB + chimera + ARM_WORLD_A + body) + # THE OTHER ARM MUST BE HOST-INDEPENDENT TOO. Reading it off the bare + # host would assert the 3.12 answer on a 3.10 runner - the round-11 red + # itself, rewritten into its own cure. Removing the accessor leaves the + # module route as the only route on EVERY interpreter, so both arms are + # constructed rather than inherited. + no_accessor = _run(PRELOAD + REMOVE_ANY_NATIVE_ACCESSOR + chimera + ARM_WORLD_A + body) + # WHICH ARM RAN IS REPORTED, NOT DEMANDED. On a real 3.10 the emulation + # stands down and prints ACCESSOR_NATIVE; demanding the EMULATED word + # would make this row red on the exact interpreter it was written for - + # round 8's lesson, and my own new pin had it until a sweep said so. + assert any(word in pre_311.stdout for word in ("ACCESSOR_EMULATED", "ACCESSOR_NATIVE")), ( + f"the pre-3.11 world said nothing about which arm it took: {pre_311.stdout}" + ) + assert "DEFECT_DIED: FileNotFoundError" in pre_311.stdout, ( + f"the pre-3.11 shape no longer reproduces the CI answer: {pre_311.stdout}" + ) + _, _, no_accessor_reaches = self._measure_route(REMOVE_ANY_NATIVE_ACCESSOR + chimera) + if no_accessor_reaches: + # 3.13-SHAPED. Removing the accessor leaves a resolve that still + # follows os.path, so the chimera moves the route rather than + # severing it and world A keeps convicting. Asserted at the value + # such a host gives, which is how this row stopped being a 3.12 + # fact wearing a portable name. + assert "DEFECT_DIED" in no_accessor.stdout, ( + "a module patch still reaches resolve with no accessor anywhere, so " + f"world A should still convict and it did not: {no_accessor.stdout}" + ) + else: + assert "DEFECT_SURVIVED" in no_accessor.stdout, ( + "with no captured accessor and no route left, the chimera still fails " + "to disarm world A, so the two arms no longer distinguish the routes: " + f"{no_accessor.stdout}" + ) + + def test_the_platform_arm_is_reachable_HERE_on_an_nt_identity_host(self, tmp_path): + """Windows red 1, reproduced on Linux and held closed forever. + + The round-9 assertion demanded ALIAS_CATCHABLE on every host. This + builds the host that answers otherwise - os.path IS ntpath, and the + realpath it reaches reads the cwd unconditionally - and checks the + judgement lands on the platform arm rather than the assertion refusing + it. Without this the nt arm above is unreachable here and the pin only + ever exercises the branch its own host produces. + """ + alias_reads, ntpath_absolute, alias_is_the_host = self._measure_alias(EMULATE_NT_HOST_IDENTITY) + assert alias_is_the_host is True, "the nt identity emulation did not take" + assert alias_reads is True, ( + "the emulated nt realpath did not read the cwd for an absolute path, so " + "this host is nt in name only and the arm below proves nothing" + ) + assert _alias_catchability(alias_reads, ntpath_absolute, alias_is_the_host) == ALIAS_LOST_TO_PLATFORM + + def test_the_nt_identity_emulation_needs_BOTH_halves(self, tmp_path): + """Control for the control, and the reason the world is two constants. + + Each half was measured alone before this pin was written. The identity + half alone leaves the aliased realpath NOT reading the cwd - the Linux + answer wearing an nt label, which is the alias trap one level up: an + emulation friendlier than the thing it stands in for. The cwd half + alone leaves ``os.path`` and ``ntpath`` distinct, so the alias is still + a foreign object and the identity claim above would be false. + + Both rows run on every host, so an edit that drops either half is a red + rather than a quieter green. + """ + _, bare_ntpath_absolute, bare_is_the_host = self._measure_alias() + reads_cwd_only, _, host_cwd_only = self._measure_alias(_NT_HOST_UNCONDITIONAL_CWD) + reads_id_only, _, host_id_only = self._measure_alias(_NT_HOST_MODULE_IDENTITY) + assert reads_cwd_only is True, "the cwd half did not take" + assert host_id_only is True, "the identity half did not take" + if bare_is_the_host: + # ALREADY NT, so neither half can discriminate: the identity half + # is a no-op and the cwd half is standing in for behaviour the host + # performs anyway. Both rows are still asserted - at their nt + # values, which are the ones an nt runner can contradict. + assert host_cwd_only is True + assert reads_id_only is True + elif bare_ntpath_absolute: + assert host_cwd_only is False, "the cwd half must not change module identity" + assert reads_id_only is False, ( + "the identity half alone made the alias read the cwd, so this host " + "no longer shows why the other half is needed" + ) + else: + # A 3.13-SHAPED NTPATH REACHES getcwd BY ITSELF, so the identity + # half alone already produces the reading behaviour and this host + # cannot show that the cwd half adds anything. Reported with the + # dimension attached; the cwd half is still asserted above. + assert host_cwd_only is False, "the cwd half must not change module identity" + assert reads_id_only is True + + @staticmethod + def _probe_literal(probe: str) -> str: + """The path a probe will actually use, read out of the probe itself. + + The pins below used to carry their own COPY of the literal, which made + them green whatever the probe said - a mutant changing ALIAS_PROBE's + path could not even be aimed, because the string it targets appeared + twice and only one of them was live. A pin that restates its subject + instead of reading it is pinning itself. + """ + assigned = [line for line in probe.splitlines() if line.strip().startswith("_abs =")] + assert len(assigned) == 1, "the probe no longer assigns its path exactly once" + value = assigned[0].split("=", 1)[1].strip() + assert value[:1] == "'" and value[-1:] == "'", f"the probe path is not a plain literal: {value}" + return value[1:-1] + + def test_the_probe_literal_is_the_SAME_SHAPE_on_every_dialect(self, tmp_path): + """Windows red 2's real mechanism, pinned as a property of the input. + + The round-9 probe built its path as `os.path.abspath(os.sep)`: '/' on + posix - rooted and driveless, the exact shape 3.13's isabs change is + about - and 'D:\\' on nt, which is DRIVE-rooted and absolute under BOTH + rules. The emulation was armed on Windows; the argument had changed + shape underneath it, so the row measured a different question and + answered it correctly. + + This pins what the literal must BE rather than trusting it to stay + written down: rooted, driveless, and read the same way by both path + modules. + """ + literal = self._probe_literal(self.ALIAS_PROBE) + probe = ( + "import ntpath, posixpath\n" + f"_abs = {literal!r}\n" + "print('NT_DRIVE:', repr(ntpath.splitdrive(_abs)[0]))\n" + "print('POSIX_ABSOLUTE:', posixpath.isabs(_abs))\n" + ) + result = _run(PRELOAD + probe) + assert "NT_DRIVE: ''" in result.stdout, f"{result.stdout}\n{result.stderr}" + assert "POSIX_ABSOLUTE: True" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_the_round_9_probe_spelling_would_still_be_host_shaped(self, tmp_path): + """The negative control for the pin above, and the CI red's mechanism + made falsifiable here: `os.path.abspath(os.sep)` under an nt-shaped + path module yields a DRIVE-rooted string, which every isabs rule calls + absolute. Reproduced by asking ntpath directly rather than by owning a + Windows box.""" + probe = ( + "import ntpath\n" + "_old_spelling = ntpath.abspath(ntpath.sep)\n" + "print('OLD_SPELLING_HAS_DRIVE:', bool(ntpath.splitdrive(_old_spelling)[0]))\n" + "print('NEW_SPELLING_HAS_DRIVE:', bool(ntpath.splitdrive('/x')[0]))\n" + ) + result = _run(PRELOAD + probe) + assert "NEW_SPELLING_HAS_DRIVE: False" in result.stdout, f"{result.stdout}\n{result.stderr}" + + @pytest.mark.parametrize( + "alias_reads,ntpath_absolute,is_host,expected", + [ + (False, True, False, ALIAS_CATCHABLE), + (False, False, False, ALIAS_CATCHABLE), + (False, True, True, ALIAS_CATCHABLE), + (False, False, True, ALIAS_CATCHABLE), + (True, True, True, ALIAS_LOST_TO_PLATFORM), + (True, False, True, ALIAS_LOST_TO_PLATFORM), + (True, False, False, ALIAS_LOST_TO_VERSION), + (True, True, False, ALIAS_LOST_TO_BEHAVIOUR), + ], + ) + def test_the_catchability_table_is_reachable_on_any_host(self, alias_reads, ntpath_absolute, is_host, expected): + """All eight combinations runnable anywhere - a literal table over a + plain function, so no row waits on an operating system to be reached. + + The two rows that pay for themselves are the last two: same + alias_reads, same identity absent, and the verdict turns on the version + alone. The eight are written out rather than generated because a + generated table hides which combination is missing. + """ + assert _alias_catchability(alias_reads, ntpath_absolute, is_host) == expected + + def test_the_313_emulation_changes_only_isabs(self, tmp_path): + """Arming probe for the reproduction. If the emulation ever stops + taking, the version row above passes for the wrong reason - which is + the failure mode this whole file exists to refuse.""" + probe = ( + "import ntpath, os\n" + "print('ROOTED_IS_ABSOLUTE:', ntpath.isabs(os.sep))\n" + "print('DRIVED_IS_ABSOLUTE:', ntpath.isabs('C:' + chr(92) + 'x'))\n" + ) + plain = _run(PRELOAD + probe) + emulated = _run(PRELOAD + self.NTPATH_313_ISABS + probe) + assert "DRIVED_IS_ABSOLUTE: True" in plain.stdout, plain.stdout + assert "DRIVED_IS_ABSOLUTE: True" in emulated.stdout, emulated.stdout + assert "ROOTED_IS_ABSOLUTE: False" in emulated.stdout, emulated.stdout + + def test_world_a_ARMS_against_a_310_shaped_pathlib(self, tmp_path): + """The cure, measured on the interpreter shape this machine does not + have. With the captured accessor patched, the faithful world convicts + against a pre-3.11 pathlib exactly as it does natively — which is what + removed the need for a version table.""" + body = _defect_body(tmp_path, DEFECT_A_SOURCE, "defect_a") + result = _run(PRELOAD + EMULATE_PY310_PATHLIB + ARM_WORLD_A + body) + assert "DEFECT_DIED" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_world_a_prime_still_convicts_against_a_310_shaped_pathlib(self, tmp_path): + """The other half, and the whole reason the stand-in exists. Same + emulated world, opposite verdict — which is what makes the pair a + discrimination rather than two worlds that happen to agree.""" + body = _defect_body(tmp_path, DEFECT_A_SOURCE, "defect_a") + result = _run(PRELOAD + EMULATE_PY310_PATHLIB + ARM_WORLD_A_PRIME + body) + assert "DEFECT_DIED" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_the_310_emulation_is_armed_and_not_merely_quiet(self, tmp_path): + """Arming probe for the emulation itself, @commons' species: a world + spelled so that nothing happens reports exactly what a cured tree + reports. The rebuilt resolve must still RESOLVE — otherwise the two pins + above pass because the emulation broke pathlib, not because it moved the + call.""" + probe = ( + "import os, pathlib\n" + "print('EMULATION_RESOLVES:', pathlib.Path(pathlib.__file__).resolve() == " + "pathlib.Path(os.path.normpath(pathlib.__file__)))\n" + "print('REALPATH_UNTOUCHED:', os.path.realpath is os.path.realpath)\n" + ) + result = _run(PRELOAD + EMULATE_PY310_PATHLIB + probe) + assert "EMULATION_RESOLVES: True" in result.stdout, f"{result.stdout}\n{result.stderr}" + + def test_a_bare_module_patch_does_NOT_reach_a_pre_captured_accessor(self, tmp_path): + """The mechanism, measured rather than asserted. + + Reproduced independently here before rebuilding the world: patching only + ``os.path.realpath`` cannot be seen through a reference captured at + class-creation time, which is exactly the state 3.10's pathlib is in + from the moment it is first imported. + + Note on my own reproduction: the first run of this appeared to REFUTE + the claim, because the probe path was ```` — relative, and + ``posixpath.realpath`` reads the cwd for a relative path whatever else + is patched. An absolute target is load-bearing here. + """ + world = EMULATE_PY310_PATHLIB + BARE_MODULE_PATCH_ONLY + body = _defect_body(tmp_path, DEFECT_A_SOURCE, "defect_a") + result = _run(PRELOAD + world + body) + assert "DEFECT_SURVIVED" in result.stdout, f"{result.stdout}\n{result.stderr}" + + +class TestEverySeedgoModuleImportsWithoutAReadableCwd: + def test_the_sweeps_world_is_ARMED_not_merely_reporting(self, world_a_result): + """Strengthened from either-outcome to ARMED, which the stand-in makes + possible on every interpreter. + + The old form accepted PROBE_VACUOUS and said so out loud — honest, but it + meant the module sweep below could pass on 3.10 against a world that + denied nothing, with the report buried in stdout nobody reads. Now that + the sweep rides world A-prime the world arms everywhere, so a vacuous + probe is a defect rather than a disclosure. + """ + assert "PROBE_ARMED" in world_a_result.stdout, world_a_result.stdout + + def test_world_a_imports_every_module(self, world_a_result): + assert "IMPORTED_ALL" in world_a_result.stdout, world_a_result.stderr + + def test_the_sweeps_world_arms_on_a_310_shaped_pathlib_too(self, world_a_result_on_310_shaped_pathlib): + """The sweep must measure something on 3.10, not merely pass there.""" + assert "PROBE_ARMED" in world_a_result_on_310_shaped_pathlib.stdout, world_a_result_on_310_shaped_pathlib.stdout + + def test_every_module_imports_on_a_310_shaped_pathlib_too(self, world_a_result_on_310_shaped_pathlib): + assert "IMPORTED_ALL" in world_a_result_on_310_shaped_pathlib.stdout, ( + world_a_result_on_310_shaped_pathlib.stderr + ) + + def test_world_b_imports_every_module(self, world_b_result): + assert "IMPORTED_ALL" in world_b_result.stdout, world_b_result.stderr + + +class TestNoSeedgoModuleResolvesAtImportOutsideTheHelper: + """The sweep that keeps the thirteenth site from being written quietly. + + Not a grep: the discriminator is REACHED AT IMPORT, and 61 of seedgo's 75 + ``.resolve()`` call sites are call-time. ``Path.resolve`` is wrapped to + record its caller while the whole tree is imported, so a default argument + evaluated at import counts and a line inside a function body does not. + """ + + RECORDER = """ +import importlib +import pathlib +import sys + +_records = [] +_real_resolve = pathlib.Path.resolve + + +def _recording_resolve(self, *a, **kw): + frame = sys._getframe(1) + _records.append((frame.f_code.co_filename, frame.f_lineno)) + return _real_resolve(self, *a, **kw) + + +pathlib.Path.resolve = _recording_resolve + +for _name in %(modules)r: + importlib.import_module(_name) + +pathlib.Path.resolve = _real_resolve + +for _filename, _lineno in sorted(set(_records)): + if "%(marker)s" in _filename.replace("\\\\", "/"): + print("SITE", _filename, _lineno) +print("SWEPT") +""" + + def _sweep(self): + world = PRELOAD + self.RECORDER % { + "modules": _seedgo_modules(), + "marker": "/seedgo/", + } + return _run(world) + + def test_the_only_import_time_resolves_left_are_guarded(self): + result = self._sweep() + assert "SWEPT" in result.stdout, result.stderr + sites = [line.split() for line in result.stdout.splitlines() if line.startswith("SITE ")] + offenders = [ + f"{Path(parts[1]).relative_to(SEEDGO_ROOT)}:{parts[2]}" + for parts in sites + if Path(parts[1]).name not in ("module_root.py", "__init__.py") + ] + assert offenders == [], f"import-time resolve() outside module_file(): {offenders}" + + def test_the_sweep_actually_visited_the_tree(self): + """Positive control on the SWEEP: a walk that imported nothing reports + no offenders and passes forever. The helper's own guarded resolve is the + witness that the recorder was installed and the tree was really read.""" + result = self._sweep() + seen = [line for line in result.stdout.splitlines() if line.startswith("SITE ")] + assert any("module_root.py" in line for line in seen), ( + f"recorder saw no module_file() call — the sweep proves nothing: {result.stdout}" + ) + + +# --------------------------------------------------------------------------- +# The caller-is-None branch, watched behaviourally as well as structurally +# --------------------------------------------------------------------------- + +_DIRECT_CALL_BODY = """ +from aipass.seedgo.apps.handlers import _find_real_caller, _guard_branch_access + +# ARMING PROBE 2: the branch under test only RUNS when _find_real_caller +# returns None. Fed on stdin every frame is or frozen importlib, both +# skipped, so it does. Without this the guard could return for some entirely +# different reason and the pin would report the same green. +print("CALLER_IS_NONE:", _find_real_caller() == (None, None)) + +_guard_branch_access() +print("GUARD_RETURNED") +""" + + +class TestTheCallerIsNoneBranchIsReachableByADirectCall: + """@devpulse relaying @spawn's correction of MY sentence, 2026-08-31. + + I published "the deleted second inspect.stack() walk is unreachable, so only + an AST ban can watch it". Too strong, and @spawn measured the correction: + unreachable from IMPORT-shaped pins — apps/__init__ always supplies a real + on-disk frame — but reachable by calling ``_guard_branch_access()`` DIRECTLY + from a child fed on stdin, where every frame is a pseudo-file or frozen + importlib and ``_find_real_caller`` therefore returns None. + + So the branch gets a behavioural pin as well as the structural one. The AST + ban still earns its place: no subprocess, and it names the defect precisely. + This one is the sibling that watches the BEHAVIOUR, and the two die to + different mutations — regrowing the walk kills both, deleting only the AST + rule leaves this standing. + + The true sentence, for the record: import-shaped pins cannot reach it. Not + "nothing can". + """ + + @staticmethod + @pytest.fixture(scope="class", params=["native", "310-shaped"]) + def result(request): + """Run on BOTH interpreter shapes. + + Parametrised to close a mutant that survived the first run: putting the + old resolve-shaped probe back changed nothing on 3.12, because + delegation makes the two probes agree there. Under the 3.10 shape the + old probe reports VACUOUS and the substitution finally has a + consequence a local run can see. + """ + shape = EMULATE_PY310_PATHLIB if request.param == "310-shaped" else "" + return _run(PRELOAD + shape + ARM_WORLD_B + PROBE_REALPATH + _DIRECT_CALL_BODY) + + def test_the_denial_bites_in_this_child(self, result): + """Arming probe 1. A world that silently failed to deny anything would + let a regrown inspect.stack() walk pass this whole class. + + MEASURES os.path.realpath, which is what world B denies and what + ``inspect`` calls directly on every interpreter. The first version asked + ``Path.resolve()`` instead — a question about PATHLIB, true on 3.11+ + only by delegation — and went red on the 3.10 CI leg over a property + world B never depended on. The world was fine; the probe was measuring + something else (@devpulse relaying the 3.10 leg of 8550ed10). + """ + assert "PROBE_ARMED" in result.stdout, result.stderr + + def test_the_branch_under_test_actually_runs(self, result): + """Arming probe 2, and @spawn's rule: a structural claim that read the + wrong thing reports exactly the same green as a clean tree. If + _find_real_caller returned a real caller here, the guard would be + exercising the ALLOW-pytest path instead and proving nothing.""" + assert "CALLER_IS_NONE: True" in result.stdout, result.stdout + + def test_the_OLD_probe_would_have_gone_vacuous_on_a_310_shaped_pathlib(self): + """Why this class's arming probe had to change, reproduced locally. + + The original probe asked ``Path.resolve()`` — a question about pathlib — + while world B denies ``os.path.realpath``. On 3.11+ delegation makes the + two agree and the substitution is invisible; against a 3.10-shaped + pathlib the old probe reports VACUOUS while the world is fully armed, + which is the red the 3.10 CI leg produced. + + Pinned so the correction is falsifiable HERE rather than only on the one + interpreter this machine does not have. + """ + old_probe = _run(PRELOAD + EMULATE_PY310_PATHLIB + ARM_WORLD_B + PROBE) + new_probe = _run(PRELOAD + EMULATE_PY310_PATHLIB + ARM_WORLD_B + PROBE_REALPATH) + assert "PROBE_VACUOUS" in old_probe.stdout, old_probe.stdout + assert "PROBE_ARMED" in new_probe.stdout, new_probe.stdout + + def test_the_guard_returns_rather_than_dying(self, result): + """The claim itself. A regrown second walk dies here under the realpath + denial; the cured plain return survives.""" + assert "GUARD_RETURNED" in result.stdout, result.stderr + assert result.returncode == 0, result.stderr diff --git a/src/aipass/seedgo/tests/test_json_durability.py b/src/aipass/seedgo/tests/test_json_durability.py index 1f7092de5..0935c6e72 100644 --- a/src/aipass/seedgo/tests/test_json_durability.py +++ b/src/aipass/seedgo/tests/test_json_durability.py @@ -197,8 +197,12 @@ def test_save_json_still_stamps_last_updated_on_data(self, json_dir: Path): assert reloaded is not None assert reloaded["last_updated"] != "1999-01-01" - def test_ensure_json_exists_still_returns_true(self, json_dir: Path): - assert json_handler.ensure_json_exists("m", "config") is True + def test_ensure_json_exists_still_creates_the_file(self, json_dir: Path): + """Was pinning an unconditional `is True`. The durability contract this + class exists to protect is that the FILE lands, not that a constant is + returned; failure is reported by exception, not by a value.""" + json_handler.ensure_json_exists("m", "config") + assert (json_dir / "m_config.json").exists() def test_round_trip_still_works(self, json_dir: Path): json_handler.save_json("m", "log", [{"entry": "one"}]) @@ -312,6 +316,67 @@ def test_no_staged_temp_files_survive_the_race(self, json_dir: Path): assert sorted(p.name for p in json_dir.iterdir()) == ["race_data.json"] +#: How long to let a suspected orphan settle before convicting it. A +#: mid-rename staging file is gone in milliseconds; a real orphan is there +#: until someone deletes it. Only paid when the first look found something, +#: so a clean run costs nothing. +ORPHAN_SETTLE_SECONDS: float = 0.75 + + +def _staging_files_now(live_dir: Path) -> set: + """Staging temps in `live_dir` that carry THIS handler's prefix. + + Args: + live_dir: The directory the live documents are written to. + + Returns: + Bare filenames. + """ + document_stems = {p.stem for p in live_dir.glob("*.json")} + return {p.name for p in live_dir.glob("*.tmp") if any(p.name.startswith(s) for s in document_stems)} + + +def _orphans_that_survive_a_settle(live_dir: Path, preexisting: set, *, settle=None, sleep=None) -> tuple: + """Look twice, and convict only what is there both times. + + THE RACE THIS CLOSES, caught by @devpulse on the round-7 commit gate: the + detector convicted `utils_logc9s1tpcu.tmp` and the file was GONE under a + minute later. It was a concurrent citizen's write through the shared handler, + caught mid-``os.replace``. The detector found exactly what it looks for - the + file simply was not an ORPHAN yet. + + That is unavoidable in a single look: a staging file and an orphan are the + same bytes in the same place, and the only thing separating them is TIME. On + a machine with live citizens (this one, always - daemon, watchers, agents + answering mail) a busy gate run will keep hitting it. + + So the discriminator is persistence, not appearance. A mid-rename temp is + gone in milliseconds; a real orphan is there until somebody deletes it. + + Args: + live_dir: The directory the live documents are written to. + preexisting: Staging files present at session start. + settle: Seconds to wait before the second look. Injected for the pins. + sleep: The sleep callable. Injected so a pin can prove the settle is + SKIPPED on a clean first look rather than merely fast. + + Returns: + `(all_ours_now, convicted)` - everything with our prefix, and the new + orphans that survived both looks. + """ + settle = ORPHAN_SETTLE_SECONDS if settle is None else settle + sleep = time.sleep if sleep is None else sleep + + ours = _staging_files_now(live_dir) + suspects = ours - preexisting + if not suspects: + return ours, set() + + sleep(settle) + ours_after = _staging_files_now(live_dir) + return ours_after, suspects & (ours_after - preexisting) + + class TestLiveDocumentsStillParse: """A fix that lands on a real branch must not orphan what is already there.""" @@ -344,15 +409,18 @@ def test_no_orphaned_temp_files_from_this_handler_in_the_live_dir(self, preexist Pre-existing orphans are warned about, never silently accepted — a baseline nobody is told about is how a leak becomes permanent. + + SCOPE BY PERSISTENCE: a staging file and an orphan are the same bytes in + the same place, separated only by time, so a single look convicts a + healthy write caught mid-rename. See `_orphans_that_survive_a_settle`. """ live_dir = json_handler.JSON_DIR if not live_dir.exists(): pytest.skip("no live json dir on this checkout") - document_stems = {p.stem for p in live_dir.glob("*.json")} - ours = {p.name for p in live_dir.glob("*.tmp") if any(p.name.startswith(s) for s in document_stems)} + ours, convicted = _orphans_that_survive_a_settle(live_dir, preexisting_live_tmp_files) for stale in sorted(ours & preexisting_live_tmp_files): warnings.warn(f"pre-existing orphan staging file in {live_dir}: {stale}", stacklevel=2) - assert sorted(ours - preexisting_live_tmp_files) == [] + assert sorted(convicted) == [] class TestHelperUsesTheAtomicPrimitives: @@ -371,3 +439,98 @@ def recording_replace(src, dst): json_handler._atomic_write_json(target, {"a": 1}) assert len(calls) == 1 assert calls[0][1] == str(target) + + +class TestTheOrphanDetectorConvictsPersistenceNotAppearance: + """@devpulse's round-7 gate weather, made falsifiable on any machine. + + The gate convicted `utils_logc9s1tpcu.tmp` and the file was gone under a + minute later - a concurrent citizen's write caught mid-``os.replace``. These + pins reproduce both sides of that race by CONSTRUCTION rather than by + waiting for a busy machine, because a race reproduced only under load is a + race that will be re-litigated every time the gate is quiet. + """ + + def _dir_with(self, tmp_path: Path, tmp_names) -> Path: + (tmp_path / "utils_log.json").write_text("{}", encoding="utf-8") + for name in tmp_names: + (tmp_path / name).write_text("partial", encoding="utf-8") + return tmp_path + + def test_a_staging_file_that_VANISHES_is_acquitted(self, tmp_path): + """The convicted-healthy case. The sleep is where the rename lands.""" + live = self._dir_with(tmp_path, ["utils_logc9s1tpcu.tmp"]) + + def rename_during_the_settle(_seconds): + (live / "utils_logc9s1tpcu.tmp").unlink() + + _, convicted = _orphans_that_survive_a_settle(live, set(), settle=0, sleep=rename_during_the_settle) + assert convicted == set() + + def test_a_staging_file_that_PERSISTS_is_still_convicted(self, tmp_path): + """The positive control, and the reason the cure is a re-check rather + than a skip. A detector that stopped convicting would pass every pin + above it and find nothing forever.""" + live = self._dir_with(tmp_path, ["utils_logc9s1tpcu.tmp"]) + _, convicted = _orphans_that_survive_a_settle(live, set(), settle=0, sleep=lambda _s: None) + assert convicted == {"utils_logc9s1tpcu.tmp"} + + def test_a_clean_first_look_does_NOT_pay_the_settle(self, tmp_path): + """Not a performance note - a correctness one. If the settle ran + unconditionally, every clean run in the fleet would wait for it, and a + cure that taxes the 99.9%% of runs it does nothing for gets deleted by + whoever is next in a hurry.""" + live = self._dir_with(tmp_path, []) + slept = [] + _, convicted = _orphans_that_survive_a_settle(live, set(), settle=99, sleep=lambda s: slept.append(s)) + assert convicted == set() + assert slept == [] + + def test_a_suspect_that_is_pre_existing_does_not_trigger_a_settle(self, tmp_path): + """The session-diff narrowing still comes FIRST. A machine carrying an + old orphan would otherwise pay the settle on every run forever.""" + live = self._dir_with(tmp_path, ["utils_logold.tmp"]) + slept = [] + ours, convicted = _orphans_that_survive_a_settle( + live, {"utils_logold.tmp"}, settle=99, sleep=lambda s: slept.append(s) + ) + assert convicted == set() + assert slept == [] + assert "utils_logold.tmp" in ours + + def test_a_foreign_prefix_is_still_out_of_scope_after_the_settle(self, tmp_path): + """The prefix narrowing survives the change. incremental_cache writes + `tmp.tmp` into the same directory and is not this handler's.""" + live = self._dir_with(tmp_path, ["tmp2ay2d070.tmp"]) + ours, convicted = _orphans_that_survive_a_settle(live, set(), settle=0, sleep=lambda _s: None) + assert convicted == set() + assert ours == set() + + def test_a_second_orphan_appearing_DURING_the_settle_is_not_convicted(self, tmp_path): + """The claim stays exactly as narrow as it was: a file that shows up + after the first look was not caught by the first look either, and + convicting it would re-open the flake from the other end - a healthy + write started DURING the settle would be convicted by its own arrival. + + MY FIRST VERSION OF THIS PIN WAS VACUOUS and a mutant said so. It seeded + an empty directory, so the first look found no suspects, the function + returned before the settle, and the late file was never even created. + The mutant that drops the intersection survived the whole file. A pin + that exercises an early return while claiming to test what comes after + it is the arming-probe defect one level down: it measured nothing and + reported the same green as a working pin. Run round 7, M17. + """ + live = self._dir_with(tmp_path, ["utils_logreal.tmp"]) + + def arrives_late(_seconds): + (live / "utils_lognew.tmp").write_text("partial", encoding="utf-8") + + _, convicted = _orphans_that_survive_a_settle(live, set(), settle=0, sleep=arrives_late) + assert convicted == {"utils_logreal.tmp"} + + def test_the_settle_is_long_enough_to_outlast_a_rename(self): + """The premise this cure rests on, stated as a number rather than left + implicit: os.replace on a local filesystem is orders of magnitude faster + than the settle. If that stops being true the cure stops working, and + this pin is where it would be noticed.""" + assert ORPHAN_SETTLE_SECONDS >= 0.5 diff --git a/src/aipass/seedgo/tests/test_json_handler.py b/src/aipass/seedgo/tests/test_json_handler.py index 43b20558b..84ba3935f 100644 --- a/src/aipass/seedgo/tests/test_json_handler.py +++ b/src/aipass/seedgo/tests/test_json_handler.py @@ -323,8 +323,7 @@ def test_get_json_path_different_combos_differ(tmp_path: Path) -> None: # JH-01 def test_ensure_creates_file_when_missing(tmp_path: Path) -> None: # JH-018 - result = json_handler.ensure_json_exists("ens_mod", "config") - assert result is True + json_handler.ensure_json_exists("ens_mod", "config") json_dir = _json_dir_as_path(tmp_path) created = json_dir / "ens_mod_config.json" assert created.exists(), "ensure_json_exists must create the file" @@ -373,10 +372,19 @@ def test_ensure_regenerates_invalid_structure(tmp_path: Path) -> None: # JH-021 assert "config" in data -def test_ensure_returns_bool(tmp_path: Path) -> None: # JH-022 - result = json_handler.ensure_json_exists("bool_mod", "data") - assert isinstance(result, bool), "ensure_json_exists must return bool" - assert result is True +def test_ensure_reports_failure_by_RAISING_not_by_returning(tmp_path: Path, monkeypatch) -> None: # JH-022 + """Was `assert result is True` against a function that returned True on every + path — a test that could not go red. The contract it should have been pinning + is that a failed write PROPAGATES rather than being reported as a value.""" + json_handler.ensure_json_exists("bool_mod", "data") + assert (_json_dir_as_path(tmp_path) / "bool_mod_data.json").exists() + + def boom(*a, **k): + raise OSError("disk full") + + monkeypatch.setattr(json_handler, "_atomic_write_json", boom) + with pytest.raises(OSError): + json_handler.ensure_json_exists("bool_mod_two", "data") # ============================================================================ @@ -561,11 +569,16 @@ def test_ensure_module_jsons_creates_all_three(tmp_path: Path) -> None: # JH-03 assert (json_dir / "triple_log.json").exists(), "Log file must exist" -def test_ensure_module_jsons_returns_true(tmp_path: Path) -> None: # JH-037 +def test_ensure_module_jsons_success_means_files_not_return_value(tmp_path: Path) -> None: # JH-037 + """Was `assert result is True` — which the function returned unconditionally + while DISCARDING the three booleans it collected, so it reported success no + matter what the three calls did. Pin the three files instead.""" if not hasattr(json_handler, "ensure_module_jsons"): pytest.skip("Branch does not have ensure_module_jsons") - result = json_handler.ensure_module_jsons("retmod") - assert result is True, "ensure_module_jsons must return True" + json_handler.ensure_module_jsons("retmod") + json_dir = _json_dir_as_path(tmp_path) + for json_type in ("config", "data", "log"): + assert (json_dir / f"retmod_{json_type}.json").exists(), json_type def test_ensure_module_jsons_files_pass_validation(tmp_path: Path) -> None: # JH-038 @@ -700,3 +713,73 @@ def test_reimport_after_mock(tmp_path: Path) -> None: handler_module = sys.modules.get(f"aipass.{BRANCH_MODULE}.apps.handlers.json.json_handler") if handler_module: importlib.reload(handler_module) + + +class TestEnsureFunctionsDoNotPromiseASignalTheyNeverSend: + """`-> bool` that is always `True` invites a branch that can never fire. + + FOUND BY DOGFOODING a rule @memory proposed 2026-08-30. Their rollover bug + was `write_memory_file_simple` reporting failure by RETURNING False while + the caller discarded the boolean — so a refusal reached nobody and every + try/except above it was decorative. They suggested a checker for it. + + Running that idea against seedgo's own tree found 194 discarded bool + returns, ~180 of them `log_operation()` where discarding is deliberate — + which is why the rule as stated is not shippable. The real positives were + here: `ensure_json_exists` and `ensure_module_jsons` were annotated + `-> bool` and returned `True` on EVERY path. The value was not a signal, it + was decoration, and a caller writing `if not ensure_json_exists(...)` would + have written a branch that can never be taken. + + Failure IS reported — `_atomic_write_json` raises OSError. These pins say + that out loud so the honest channel cannot be quietly replaced by a boolean + that only ever means one thing. + """ + + def test_ensure_json_exists_does_not_advertise_a_bool_return(self): + import typing + + from aipass.seedgo.apps.handlers.json import json_handler + + hints = typing.get_type_hints(json_handler.ensure_json_exists) + assert hints.get("return") is not bool, ( + "a return type that is always True promises a failure signal that never arrives" + ) + + def test_ensure_module_jsons_does_not_advertise_a_bool_return(self): + import typing + + from aipass.seedgo.apps.handlers.json import json_handler + + hints = typing.get_type_hints(json_handler.ensure_module_jsons) + assert hints.get("return") is not bool + + def test_a_failing_write_RAISES_rather_than_returning_a_falsy_value(self, tmp_path, monkeypatch): + """The honest channel, pinned: failure propagates.""" + import pytest + + from aipass.seedgo.apps.handlers.json import json_handler + + def boom(*a, **k): + raise OSError("disk full") + + monkeypatch.setattr(json_handler, "JSON_DIR", tmp_path) + monkeypatch.setattr(json_handler, "_atomic_write_json", boom) + + with pytest.raises(OSError): + json_handler.ensure_json_exists("nonexistent_module_xyz", "config") + + def test_ensure_module_jsons_propagates_instead_of_reporting_success(self, tmp_path, monkeypatch): + """The original defect: three discarded bools, then `return True`.""" + import pytest + + from aipass.seedgo.apps.handlers.json import json_handler + + def boom(*a, **k): + raise OSError("disk full") + + monkeypatch.setattr(json_handler, "JSON_DIR", tmp_path) + monkeypatch.setattr(json_handler, "_atomic_write_json", boom) + + with pytest.raises(OSError): + json_handler.ensure_module_jsons("nonexistent_module_xyz") diff --git a/src/aipass/seedgo/tests/test_posix_literal_nominator.py b/src/aipass/seedgo/tests/test_posix_literal_nominator.py new file mode 100644 index 000000000..bc4e66306 --- /dev/null +++ b/src/aipass/seedgo/tests/test_posix_literal_nominator.py @@ -0,0 +1,215 @@ +# =================== AIPass ==================== +# Name: test_posix_literal_nominator.py +# Description: pins for the POSIX-LITERAL nominator +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +r"""Pins for the rooted-path-literal nominator. + +THE FACT THIS RULE DEPENDS ON is pinned first, because @drone's round-7 note is +right: every rule has a premise underneath it and almost none of them say so out +loud. Here the premise is that ntpath treats a rooted literal as DRIVE-RELATIVE +while posixpath treats it as absolute. If that ever stops being true this file +goes red rather than quietly nominating a defect nobody can hit. +""" + +import ast +import ntpath +import posixpath +from pathlib import Path + +import pytest + +from aipass.seedgo.apps.handlers.tests_pytest_standards import corpus +from aipass.seedgo.apps.handlers.tests_pytest_standards import posix_literal_check as target + + +def _nominate(tmp_path: Path, source: str) -> list: + """Run the nominator over one synthetic test module.""" + tests_dir = tmp_path / "tests" + tests_dir.mkdir(exist_ok=True) + (tests_dir / "test_subject.py").write_text(source, encoding="utf-8") + ast.parse(source) # a fixture that will not parse exercises the wrong arm + scanned = corpus.build(tmp_path, ("tests",)) + return target.nominate(scanned) + + +class TestTheHazardItself: + """The platform fact the whole rule rests on, measured not assumed.""" + + def test_ntpath_does_not_call_a_rooted_literal_absolute(self): + """The premise. ntpath.isabs is True for a rooted literal, but the path + carries no DRIVE, which is what makes resolve() attach the current one + and hand back something the author never wrote.""" + assert ntpath.splitdrive("/tmp") == ("", "/tmp") + assert posixpath.splitdrive("/tmp") == ("", "/tmp") + # The half that differs: ntpath joins a drive onto it, posixpath cannot. + assert ntpath.join("D:", "/tmp") == "D:/tmp" + + def test_a_rooted_literal_is_drive_relative_on_ntpath_only(self): + """Same literal, two answers. This is the whole species in two lines.""" + assert ntpath.abspath("D:/x/../tmp") == "D:\\tmp" + assert posixpath.abspath("/x/../tmp") == "/tmp" + + +class TestWhatItNominates: + """The construct that took @drone's windows-setup leg down.""" + + def test_a_path_literal_put_through_resolve_is_nominated(self, tmp_path): + rows = _nominate( + tmp_path, + 'from pathlib import Path\n\n\ndef test_x():\n assert Path("/tmp").resolve() in roots\n', + ) + assert len(rows) == 1, rows + assert rows[0]["evidence"]["literal"] == "/tmp" + + def test_os_path_realpath_over_a_literal_is_nominated(self, tmp_path): + rows = _nominate( + tmp_path, + 'import os\n\n\ndef test_x():\n assert os.path.realpath("/tmp") == "/tmp"\n', + ) + assert len(rows) == 1, rows + + def test_a_windows_rooted_literal_is_nominated_TOO(self, tmp_path): + r"""The rule is not "POSIX spelling bad". A hardcoded C:\ is the same + claim pointing the other way, and a rule that only caught one direction + would be a platform preference wearing a portability name.""" + rows = _nominate( + tmp_path, + 'from pathlib import Path\n\n\ndef test_x():\n assert Path("C:\\\\tmp").resolve()\n', + ) + assert len(rows) == 1, rows + + def test_the_nomination_names_the_literal_not_just_the_line(self, tmp_path): + rows = _nominate( + tmp_path, + 'from pathlib import Path\n\n\ndef test_x():\n assert Path("/etc/passwd").resolve()\n', + ) + assert "/etc/passwd" in rows[0]["why"] + + +class TestWhatItAcquits: + """The acquittals are most of this rule - measured before it shipped.""" + + def test_another_objects_resolve_verb_is_NOT_nominated(self, tmp_path): + """The measurement that decided the shape. Six of the ten sites a + name-keyed rule found fleet-wide were this: a branch-name resolver + sharing a verb with pathlib and holding a rooted literal in a dict value + it never resolves. Keyed on the receiver, none of them are nominated.""" + rows = _nominate( + tmp_path, + "def test_x():\n" + ' resolved = registry.resolve("@canary", {"CANARY": Path("/x/canary")})\n' + " assert resolved\n", + ) + assert rows == [] + + def test_any_objects_resolve_handed_a_rooted_literal_DIRECTLY_acquits(self, tmp_path): + """The receiver test carrying its own weight, and it needed a second pin. + + The first acquittal above holds the literal inside a dict, so a mutant + that dropped the receiver test and scanned the arguments instead SURVIVED + it - the mutation was invisible to the only pin guarding the clause. + This one hands the literal straight to a foreign resolve, where nothing + but the receiver test can acquit it. Run round 7, M8. + """ + rows = _nominate( + tmp_path, + 'def test_x():\n assert branch_registry.resolve("/tmp")\n', + ) + assert rows == [] + + def test_a_realpath_on_a_NON_PATH_module_acquits(self, tmp_path): + """Guards the os.path clause, which no pin reached until a mutant + deleting it survived the whole file. Run round 7, M11.""" + rows = _nominate( + tmp_path, + 'def test_x():\n assert translator.realpath("/tmp")\n', + ) + assert rows == [] + + def test_a_relative_literal_is_NOT_nominated(self, tmp_path): + """A relative fragment makes no platform claim to disagree about.""" + rows = _nominate( + tmp_path, + 'from pathlib import Path\n\n\ndef test_x():\n assert Path("tmp/x").resolve()\n', + ) + assert rows == [] + + def test_a_path_from_a_fixture_is_NOT_nominated(self, tmp_path): + rows = _nominate( + tmp_path, + "def test_x(tmp_path):\n assert tmp_path.resolve()\n", + ) + assert rows == [] + + def test_a_resolve_on_a_variable_is_NOT_nominated(self, tmp_path): + """The stated limit, pinned so it is a decision rather than a surprise: + this rule reads the RECEIVER, so a literal that travelled through a name + is invisible to it. It errs SHORT.""" + rows = _nominate( + tmp_path, + 'from pathlib import Path\n\n\ndef test_x():\n p = Path("/tmp")\n assert p.resolve()\n', + ) + assert rows == [] + + def test_an_empty_literal_is_NOT_nominated(self, tmp_path): + rows = _nominate( + tmp_path, + 'from pathlib import Path\n\n\ndef test_x():\n assert Path("").resolve()\n', + ) + assert rows == [] + + +class TestTheNominatorShape: + """The pack contract, so a broken group cannot ship green.""" + + def test_it_declares_a_group_and_a_specification(self): + assert target.GROUP == "static_posix_literal" + assert target.SPECIFICATION["species"] == ["POSIX-LITERAL"] + + def test_it_is_registered_with_the_adapter(self): + from aipass.seedgo.apps.handlers.tests_pytest_standards import adapter + + assert target.GROUP in adapter.STATIC_GROUPS + + def test_it_does_not_define_a_module_or_branch_check(self): + """A nominator that grows a check_module becomes a convicting tier by + accident (Law M1).""" + assert not hasattr(target, "check_module") + assert not hasattr(target, "check_branch") + + def test_the_spec_states_the_limit_the_live_run_actually_hit(self): + """Not decoration: running this over @drone's tree found 3 of the 4 + sites a whole-file scan finds, because the fourth sits in a fixture. + The limits list has to say so.""" + limits = " ".join(target.SPECIFICATION["limits"]) + assert "fixture" in limits and "FEWER" in limits + + def test_the_fix_does_not_tell_anyone_to_delete_the_literal(self): + """A rooted literal is drive-relative on Windows, not invalid. A fix + line that says 'remove it' would break tests whose subject IS the + literal.""" + assert "keep it" in target.SPECIFICATION["fix"] + + @pytest.mark.parametrize( + "text,expected", + [ + ("/tmp", True), + ("\\\\server\\share", True), + ("C:/tmp", True), + ("c:\\tmp", True), + ("tmp", False), + ("", False), + ("1:/tmp", False), + ("./tmp", False), + ], + ) + def test_the_rooted_test_covers_both_dialects(self, text, expected): + """A literal table, so this one cannot vanish.""" + assert target._is_rooted_literal(ast.Constant(value=text)) is expected + + def test_a_non_string_constant_is_not_rooted(self): + assert target._is_rooted_literal(ast.Constant(value=47)) is False diff --git a/src/aipass/seedgo/tests/test_registry_case_sweep.py b/src/aipass/seedgo/tests/test_registry_case_sweep.py new file mode 100644 index 000000000..02b08024a --- /dev/null +++ b/src/aipass/seedgo/tests/test_registry_case_sweep.py @@ -0,0 +1,270 @@ +"""Registry discovery must be case-EXACT, and no lane may glob for one again. + +THE DEFECT (@devpulse relaying @drone, 2026-08-31, found on ef029782's +windows-setup leg): ``Path.glob("*_REGISTRY.json")`` matches case-INSENSITIVELY +on Windows and on default macOS, so ``*_registry.json`` matched too. Every +branch carries bait — ten ``flow_json/*_registry.json`` plan counters and +``.spawn/.template_registry.json``, which pathlib's ``*`` matches despite the +leading dot. Measured on CI, ``find_registry`` returned +``drone_command_registry.json`` as the trust-anchor candidate. + +For seedgo the stakes are discovery and bypass scoping: an audit that finds +branches through a plan counter audits the wrong world, and a bypass keyed off +the wrong registry grants an exemption nobody declared. + +THESE PINS RUN RED ON LINUX. The widened listing is EMULATED by wrapping the +real ``Path.glob`` so it also yields case-folded matches, and every emulated +test carries a positive control that exercises the REAL pathlib API — not a +re-implementation of the matching, which would prove only that the copy works +(@aipass's lesson, same night, from a control that visited zero files). +""" + +import ast +import fnmatch +import json +import re +from pathlib import Path + +import pytest + +from aipass.seedgo.apps.handlers import registry_scan + +REAL_ANCHOR = "AIPASS_REGISTRY.json" +COUNTER_DECOY = "flow_plan_registry.json" +DOT_DECOY = ".template_registry.json" + + +@pytest.fixture +def widened_glob(monkeypatch): + """Emulate a case-insensitive filesystem's LISTING, not the caller's matching. + + Wraps pathlib's own ``Path.glob`` so a pattern also matches names that differ + only in case — which is what Windows and default macOS do to the pattern the + nine deleted sites passed in. + """ + real_glob = Path.glob + + def folded_glob(self, pattern, *args, **kwargs): + seen = {p.name: p for p in real_glob(self, pattern, *args, **kwargs)} + matcher = re.compile(fnmatch.translate(pattern), re.IGNORECASE) + entries = list(self.iterdir()) if self.is_dir() else [] + for entry in entries: + if entry.name not in seen and matcher.match(entry.name): + seen[entry.name] = entry + for name in sorted(seen): + yield seen[name] + + monkeypatch.setattr(Path, "glob", folded_glob) + return folded_glob + + +def host_folds_glob(directory: Path) -> bool: + """Does THIS filesystem fold case inside a glob PATTERN? + + Travels the DEFECT'S OWN DIRECTION (@ai_mail's lesson): write a lowercase + name, glob the UPPERCASE pattern — which is exactly what the nine deleted + sites did. Probing the other way round answers a different question and can + answer it differently. + + DISTINCT STEMS, always (@memory's coexistence fact): two names differing + only by case CANNOT coexist on a folding filesystem — the second write + overwrites the FIRST'S CONTENT while the directory keeps the first's + spelling, so a case-twin probe would be measuring a world it created. + + Args: + directory: A writable directory; the probe gets its own subdirectory. + + Returns: + True if the host's glob matched a name the pattern only matches when + case is folded. + """ + probe_dir = directory / "casefold_probe" + probe_dir.mkdir(exist_ok=True) + (probe_dir / "probe_registry.json").write_text("{}", encoding="utf-8") + return [p.name for p in probe_dir.glob("*_REGISTRY.json")] != [] + + +def _project(tmp_path): + """A real anchor at the project root and a lowercase counter one level down. + + The decoy MAPS SOMEWHERE WRONG on purpose: a lane that returns it does not + merely pick a different file, it discovers a different world. + """ + root = tmp_path / "project" + lane = root / "sub" / "flow_json" + lane.mkdir(parents=True) + (root / REAL_ANCHOR).write_text( + json.dumps({"branches": {"seedgo": {"path": "src/aipass/seedgo"}}}), encoding="utf-8" + ) + (lane / COUNTER_DECOY).write_text( + json.dumps({"branches": {"NOT_A_BRANCH": {"path": "/nowhere"}}}), encoding="utf-8" + ) + return root, lane + + +class TestTheEmulationGenuinelyWidens: + """A positive control that fails if the instrument under test is asleep.""" + + def test_pathlib_glob_finds_the_lowercase_decoy_under_emulation(self, tmp_path, widened_glob): + _root, lane = _project(tmp_path) + found = [p.name for p in lane.glob("*_REGISTRY.json")] + assert COUNTER_DECOY in found, "emulation is not widening — every red below would be vacuous" + + def test_the_raw_glob_matches_the_host_it_is_running_on(self, tmp_path): + """The negative control, PROBED rather than assumed. + + Its first spelling asserted the raw glob finds nothing — true on this + Linux runner and FALSE on real Windows, where NTFS folds and the decoy + comes straight back. A control that fails on the exact host the defect + lives on is worse than none: it turns the CI leg that found the bug red + for the instrument's own reason. Both outcomes are pinned; nothing is + skipped (@memory's ruling: never skipif what a probe can measure). + """ + _root, lane = _project(tmp_path) + found = [p.name for p in lane.glob("*_REGISTRY.json")] + if host_folds_glob(tmp_path): + assert COUNTER_DECOY in found, "host folds case but the raw glob missed the decoy" + else: + assert found == [], "host is case-sensitive but the raw glob matched anyway" + + def test_the_probe_answers_from_the_filesystem_not_from_sys_platform(self, tmp_path, widened_glob): + """Probe integrity (@aipass's trick), and it is killable HERE. + + Under the emulation this Linux runner behaves like a folding host, so a + probe that read ``sys.platform`` would still answer False and this line + goes red. The opposite mutant — a probe hardwired to True — dies in the + test above, which on this case-sensitive host demands an empty listing. + Both directions have a killer on whichever host is running. + """ + assert host_folds_glob(tmp_path) is True + + +class TestDiscoveryPointsAtTheRealAnchorOnACaseFoldingFilesystem: + def test_the_upward_walk_skips_the_counter_and_keeps_climbing(self, tmp_path, widened_glob): + root, lane = _project(tmp_path) + found = registry_scan.find_registry_upward(lane) + assert found == root / REAL_ANCHOR + + def test_what_it_finds_maps_the_real_world(self, tmp_path, widened_glob): + """The assertion is about where discovery POINTS, not set membership.""" + _root, lane = _project(tmp_path) + found = registry_scan.find_registry_upward(lane) + assert found is not None + assert "seedgo" in json.loads(found.read_text(encoding="utf-8"))["branches"] + + def test_the_directory_listing_excludes_the_counter(self, tmp_path, widened_glob): + _root, lane = _project(tmp_path) + assert registry_scan.registries_in(lane) == [] + + def test_caller_registries_skips_the_counter_too(self, tmp_path, widened_glob, monkeypatch): + root, lane = _project(tmp_path) + monkeypatch.setenv(registry_scan.CALLER_CWD_VAR, str(lane)) + assert registry_scan.caller_registries() == [root / REAL_ANCHOR] + + +class TestTheNameCheckIsCaseExactWithoutAnyEmulation: + """These need no filesystem trick: a checker that folds case fails them on Linux.""" + + def test_a_lowercase_counter_is_not_a_registry(self, tmp_path): + (tmp_path / COUNTER_DECOY).write_text("{}", encoding="utf-8") + assert registry_scan.registries_in(tmp_path) == [] + + def test_a_dotted_counter_is_not_a_registry(self, tmp_path): + """pathlib's ``*`` matches dotfiles where the glob module does not.""" + (tmp_path / DOT_DECOY).write_text("{}", encoding="utf-8") + assert registry_scan.registries_in(tmp_path) == [] + + def test_an_external_projects_own_registry_survives(self, tmp_path): + """SUFFIX only, never the stem: nothing promises an uppercase stem, and + Vera-Studio_REGISTRY.json is a real one in the live fleet.""" + target = tmp_path / "Vera-Studio_REGISTRY.json" + target.write_text("{}", encoding="utf-8") + assert registry_scan.registries_in(tmp_path) == [target] + + def test_a_lowercase_stem_still_survives(self, tmp_path): + target = tmp_path / "vera-studio_REGISTRY.json" + target.write_text("{}", encoding="utf-8") + assert registry_scan.registries_in(tmp_path) == [target] + + def test_a_directory_wearing_the_name_is_not_a_registry(self, tmp_path): + (tmp_path / f"folder{registry_scan.REGISTRY_SUFFIX}").mkdir() + assert registry_scan.registries_in(tmp_path) == [] + + def test_a_missing_directory_answers_empty_rather_than_raising(self, tmp_path): + assert registry_scan.registries_in(tmp_path / "absent") == [] + + def test_the_walk_answers_None_when_nothing_is_above(self, tmp_path): + (tmp_path / COUNTER_DECOY).write_text("{}", encoding="utf-8") + assert registry_scan.find_registry_upward(tmp_path) is None + + +class TestEveryLaneReadsTheSharedReader: + """The four private walks are gone; each name now delegates. + + Patched on the MODULE so the assertion proves a call-time lookup — a lane + that had done ``from ... import find_registry`` would keep its own binding + and these would pass while reading something else. + """ + + @pytest.mark.parametrize( + "module_path", + [ + "aipass.seedgo.apps.handlers.audit.discovery", + "aipass.seedgo.apps.handlers.bypass.bypass_handler", + "aipass.seedgo.apps.handlers.diagnostics.discovery", + "aipass.seedgo.apps.handlers.readme.readme_ops", + ], + ) + def test_the_lane_returns_what_the_shared_reader_returns(self, module_path, monkeypatch, tmp_path): + import importlib + + module = importlib.import_module(module_path) + sentinel = tmp_path / "SENTINEL_REGISTRY.json" + monkeypatch.setattr(registry_scan, "find_registry", lambda: sentinel) + assert module._find_registry() == sentinel + + def test_the_caller_lane_delegates_too(self, monkeypatch, tmp_path): + from aipass.seedgo.apps.handlers.audit import discovery + + sentinel = [tmp_path / "SENTINEL_REGISTRY.json"] + monkeypatch.setattr(registry_scan, "caller_registries", lambda: sentinel) + assert discovery._find_caller_registries() == sentinel + + +class TestNoLaneGlobsForARegistryAnyMore: + """A parse-tree sweep, so the tenth walk cannot be written quietly. + + Named offenders, not a count: a sweep that reports a number tells the next + reader nothing about where to look. + """ + + def _glob_call_sites(self, tree, path): + offenders = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + if node.func.attr not in ("glob", "rglob"): + continue + for arg in node.args: + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + if any(char.isupper() for char in Path(arg.value).name): + offenders.append(f"{path}:{node.lineno} {arg.value}") + return offenders + + def test_no_cased_glob_pattern_survives_in_the_source_tree(self): + source_root = Path(__file__).resolve().parents[1] / "apps" + offenders = [] + for py_file in sorted(source_root.rglob("*.py")): + tree = ast.parse(py_file.read_text(encoding="utf-8")) + offenders.extend(self._glob_call_sites(tree, py_file.relative_to(source_root))) + assert offenders == [], f"cased glob patterns fold on Windows: {offenders}" + + def test_the_sweep_can_see_an_offender(self): + """Positive control on the INSTRUMENT: the same walker, on a real file + holding the deleted spelling — not a re-implementation of the match.""" + tree = ast.parse('from pathlib import Path\nx = Path(".").glob("*_REGISTRY.json")\n') + assert self._glob_call_sites(tree, Path("probe.py")) != [] + + def test_the_sweep_leaves_lowercase_patterns_alone(self): + tree = ast.parse('from pathlib import Path\nx = Path(".").rglob("*.py")\n') + assert self._glob_call_sites(tree, Path("probe.py")) == [] diff --git a/src/aipass/skills/README.md b/src/aipass/skills/README.md index ec11289e8..5cccfa56b 100644 --- a/src/aipass/skills/README.md +++ b/src/aipass/skills/README.md @@ -5,7 +5,7 @@ **Purpose:** Capability framework for AI agents in AIPass. Skills are discoverable, validatable, and executable units of capability that any AI agent can use. **Module:** `skills` **Created:** 2026-03-07 -**Last Updated:** 2026-08-25 +**Last Updated:** 2026-08-31 --- @@ -204,6 +204,7 @@ src/aipass/skills/ handlers/ json/ # JSON handler (three-JSON pattern) discovery_handler.py # Search paths, SKILL.md scanning, frontmatter parsing + module_paths.py # Dead-cwd-safe module location (stdlib only) loader_handler.py # Full SKILL.md parse, dynamic handler import runner_handler.py # Handler dispatch + markdown-only output creator_handler.py # Skill creation logic (name validation, orchestration) @@ -222,11 +223,92 @@ src/aipass/skills/ artifacts/ # Birth certificate and branch artifacts logs/ # prax log output .trinity/ # Branch identity and memory - tests/ # Test suite (324 passing, 1 skipped) + tests/ # Test suite (355 passing, 1 skipped) ``` --- +## Running Where The Working Directory Is Gone + +Every skills module imports without a readable current directory. + +`ntpath.realpath` calls `os.getcwd()` on its first lines **unconditionally** — +before it checks whether the path is even relative. `posixpath.realpath` reads +the cwd only for relative paths, which is why this stayed invisible on Linux. +`Path.resolve()` routes through realpath, so on Windows any +`Path(__file__).resolve()` *reached at import* is an import-time crash for a +process whose directory was deleted or whose network share dropped. Not +"degrades" — cannot import. + +This matters more here than in most branches: skill units run in host processes +nobody in this branch chose — the telegram relay, cron-fired lanes, hook +subprocesses — and those are exactly the processes likely to hold a dead or +foreign working directory. + +Every module-level location goes through one helper: + +```python +from aipass.skills.apps.handlers.module_paths import module_file + +_BRANCH_ROOT = module_file(__file__).parents[3] +``` + +`module_file()` is **stdlib-only on purpose**: importing prax would put the +logger's own construction — which reads the cwd — onto the very path the helper +protects. When resolve fails it reports once per path on stderr and returns the +unresolved absolute spelling, which loses symlink normalisation and nothing +else. + +Two sites guard themselves inline instead, because they run where the helper +cannot be imported: `apps/skills.py` (the block runs *before* any aipass import +by design, removing the shadowing path that would resolve `aipass`) and +`tools/verify_branch.py` (ships inside spawn's agent template, where `aipass` +is not importable at all). + +The handlers guard walks frames with `sys._getframe` rather than +`inspect.stack()`. inspect materialises a FrameInfo per frame, and +`getmodule()` calls `os.path.realpath` at `inspect.py:1009` outside any try — +so the guard needed a readable cwd before a single line of its own code ran, +and every module in this branch imports through it. + +The worlds themselves are defined once, in `tests/dead_cwd_world.py`. They +patch pathlib's pre-3.11 `_NormalAccessor` as well as the module name: that +accessor **captured** its copies of `os.getcwd` and `os.path.realpath` when +pathlib was first imported, so on Python 3.10 a bare module rebind patches a +name nothing reads again and the world never arms. Two of these pins were +vacuously green on the 3.10 CI leg for exactly that reason. There is no 3.10 on +this machine, so the capture is rebuilt locally on whatever interpreter is +running and the discrimination is falsifiable here rather than derived. + +That rebuilt accessor captures a **sentinel** for realpath, not the host's. +An instrument must not import behaviour it is not testing: the question it +asks is "did the patch reach the captured attribute", and a live capture makes +the answer depend on the dialect — on nt the accessor reads the cwd on its own +account, so a world that reached nothing still answers *raised* and the probe +convicts the host. The same file carries `posixpath`- and `ntpath`-shaped +realpaths written **by name and by behaviour**, never by aliasing the dialect's +own `realpath` (off Windows `ntpath.realpath` is a wrapper around `abspath` and +leaves an absolute path alone, so an nt world built by aliasing never arms). +Every accessor pin runs under both dialects and must return the same verdict; +one shape that is *supposed* to differ is held alongside them, so a litmus that +reached nothing cannot pass quietly. Where a claim really is per-platform — a +relative arming path raises in both dialects, an absolute one only on nt — it +is written as a two-row table with both rows measured here and a pin requiring +the live host to agree with its own row. + +Pinned by `tests/test_dead_cwd_imports.py`, which imports every skills module +in a child process under two denial worlds (deny `getcwd`; deny `realpath`), +plus a healthy baseline. Both worlds carry a control proving the world is live, +and a control proving that control can say no. + +Two runtime paths are covered there too, because skill units resolve them in +those same host processes: skill **discovery** drops the project search path +when there is no current directory and keeps serving global and builtin skills, +while skill **creation** refuses outright — it writes, and a target that cannot +be computed must not be guessed at. + +--- + ## Integration Points ### Depends On @@ -244,7 +326,7 @@ src/aipass/skills/ --- -*Last Updated: 2026-08-25* +*Last Updated: 2026-08-31* --- [← Back to AIPass](../../../README.md) \ No newline at end of file diff --git a/src/aipass/skills/apps/handlers/__init__.py b/src/aipass/skills/apps/handlers/__init__.py index 88b24ec74..949c1aa30 100644 --- a/src/aipass/skills/apps/handlers/__init__.py +++ b/src/aipass/skills/apps/handlers/__init__.py @@ -1,6 +1,7 @@ """Skills handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "skills" @@ -17,27 +18,57 @@ def _find_real_caller(): - Frozen modules Returns tuple: (file_path, import_line) or (None, None) + + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on + the Windows CI gate 2026-08-31: inspect.stack() builds a FrameInfo per + frame, and getsourcefile() -> getmodule() calls os.path.realpath() at + inspect.py:1009 OUTSIDE any try. ntpath.realpath calls os.getcwd() on its + first lines unconditionally, before it checks whether the path is even + relative, so on Windows this guard needed a readable cwd before a single + line of its own code ran — and every module in this branch imports through + here. On POSIX the equivalent raise happens earlier, inside getabsfile(), + where inspect swallows it, which is why this was invisible on Linux for as + long as it existed. A frame's co_filename is already a string in memory; + reading it touches nothing. """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) + # Path.resolve() reaches the same realpath, so this is guarded too — + # __file__ is already absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back + continue - for frame_info in stack: - filename = frame_info.filename + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename # Skip this file - if this_file in str(Path(filename).resolve()): + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - # Skip Python internals - if filename.startswith("<") or "importlib" in filename: - continue - - # Found a real file - try to get the import line - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + # linecache is what inspect used for code_context; called directly it + # reads one named file and returns "" rather than raising when it cannot. + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -64,33 +95,15 @@ def _guard_branch_access(): import os if os.environ.get("AIPASS_DEBUG_GUARD"): - import sys - sys.stderr.write(f"[GUARD DEBUG] caller_file = {caller_file}\n") sys.stderr.write(f"[GUARD DEBUG] import_line = {import_line}\n") if caller_file is None: - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - target_line = "unknown" - if frame.code_context: - target_line = frame.code_context[0].strip() - raise ImportError( - f"\n{'=' * 60}\n" - f"ACCESS DENIED: Cross-branch handler import blocked\n" - f"{'=' * 60}\n" - f" Caller: interactive/script\n" - f" Blocked: {target_line}\n" - f"\n" - f" Handlers are internal to their branch.\n" - f" Use the module API instead:\n" - f" from {MODULE_PATH}.apps.modules. import \n" - f"\n" - f" For full standards guide:\n" - f" drone @seedgo handlers\n" - f"{'=' * 60}" - ) + # No caller outside this file: an interactive session, a -c script, or + # an importlib-only stack. All three are allowed. This used to walk + # inspect.stack() a SECOND time looking for / — a second + # copy of the cwd dependency above, in service of a branch that + # returned either way. return # Check if caller is from our branch diff --git a/src/aipass/skills/apps/handlers/creator_handler.py b/src/aipass/skills/apps/handlers/creator_handler.py index e4498104f..acce18a08 100644 --- a/src/aipass/skills/apps/handlers/creator_handler.py +++ b/src/aipass/skills/apps/handlers/creator_handler.py @@ -83,7 +83,21 @@ def create_skill(name, template_type="markdown_only", target_dir=None): # Determine target directory if target_dir is None: - target_dir = Path.cwd() / ".aipass" / "skills" + # Unlike discovery, this one WRITES. A default that cannot be computed + # must refuse, never fall back to some other directory - creating a + # skill in the wrong tree is worse than not creating one. + try: + target_dir = Path.cwd() / ".aipass" / "skills" + except OSError as exc: + return { + "success": False, + "path": None, + "files": [], + "error": ( + f"No readable working directory ({exc}); cannot place a skill " + f"in the current project. Pass an explicit target directory." + ), + } target_path = Path(target_dir) / name diff --git a/src/aipass/skills/apps/handlers/discovery_handler.py b/src/aipass/skills/apps/handlers/discovery_handler.py index a78a46fc9..c889dc3cf 100644 --- a/src/aipass/skills/apps/handlers/discovery_handler.py +++ b/src/aipass/skills/apps/handlers/discovery_handler.py @@ -21,6 +21,7 @@ from aipass.prax import logger from aipass.skills.apps.handlers.json import json_handler +from aipass.skills.apps.handlers.module_paths import module_file # Try yaml, fall back to simple parser yaml = None @@ -47,15 +48,25 @@ def get_search_paths(): paths = [] # 1. Current project - project_path = Path.cwd() / ".aipass" / "skills" - paths.append((project_path, "project")) + # Skill units run in host processes this branch did not choose — the + # telegram relay, cron-fired lanes, hook subprocesses — and those can hold + # a working directory that has been deleted or whose share has dropped. + # "the current project" then has no answer, so this path is SKIPPED rather + # than guessed at: global and builtin skills still resolve, and discovery + # keeps working instead of raising FileNotFoundError at every caller. + try: + project_path = Path.cwd() / ".aipass" / "skills" + except OSError as exc: + logger.warning("No readable working directory (%s) - skipping the project search path", exc) + else: + paths.append((project_path, "project")) # 2. Global user global_path = Path.home() / ".aipass" / "skills" paths.append((global_path, "global")) # 3. Built-in lib - builtin_path = Path(__file__).resolve().parent.parent.parent / "lib" + builtin_path = module_file(__file__).parent.parent.parent / "lib" paths.append((builtin_path, "builtin")) return paths diff --git a/src/aipass/skills/apps/handlers/json/json_handler.py b/src/aipass/skills/apps/handlers/json/json_handler.py index 169eda69f..a238b0d3a 100644 --- a/src/aipass/skills/apps/handlers/json/json_handler.py +++ b/src/aipass/skills/apps/handlers/json/json_handler.py @@ -15,18 +15,20 @@ import json import os +import sys import tempfile import time from pathlib import Path from datetime import datetime from typing import Dict, Any, Optional -import inspect from aipass.prax import logger +from aipass.skills.apps.handlers.module_paths import module_file + # Infrastructure -_BRANCH_ROOT = Path(__file__).resolve().parents[3] +_BRANCH_ROOT = module_file(__file__).parents[3] # Constants SKILLS_JSON_DIR = _BRANCH_ROOT / "skills_json" @@ -131,17 +133,24 @@ def _get_caller_module_name() -> str: Module name (e.g., "discovery" from discovery.py) """ try: - stack = inspect.stack() - # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller - if len(stack) > 2: - caller_frame = stack[2] - caller_path = Path(caller_frame.filename) - module_name = caller_path.stem - - # Validate module name - if module_name and not module_name.startswith("_"): - return module_name + # sys._getframe rather than inspect.stack(): inspect materialises a + # FrameInfo for EVERY frame, and getsourcefile() -> getmodule() calls + # os.path.realpath at inspect.py:1009 outside any try. ntpath.realpath + # reads os.getcwd() unconditionally, so on Windows a caller with a dead + # or disconnected working directory turned every log_operation into a + # crash. This is the audit trail's hot path — one frame read answers + # the same question the whole-stack walk did. + # Frames: [0]=this function, [1]=log_operation, [2]=actual caller. + caller_frame = sys._getframe(2) + module_name = Path(caller_frame.f_code.co_filename).stem + + # Validate module name + if module_name and not module_name.startswith("_"): + return module_name + return "unknown" + except ValueError: + # Stack shallower than three frames: nobody to name. return "unknown" except Exception: logger.warning("Failed to detect caller module name from stack") diff --git a/src/aipass/skills/apps/handlers/module_paths.py b/src/aipass/skills/apps/handlers/module_paths.py new file mode 100644 index 000000000..a043f8eb3 --- /dev/null +++ b/src/aipass/skills/apps/handlers/module_paths.py @@ -0,0 +1,98 @@ +# =================== AIPass ==================== +# Name: module_paths.py +# Description: Dead-cwd-safe module location for skills +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +""" +Module location that survives an unreadable current directory. + +WHY THIS EXISTS. ``Path.resolve()`` routes through ``os.path.realpath``, and +``ntpath.realpath`` calls ``os.getcwd()`` on its first lines UNCONDITIONALLY — +before it checks whether the path is even relative. ``posixpath.realpath`` +reads the cwd only for relative paths, which is why this was invisible on +Linux. A process whose working directory has been deleted, or which sits on a +disconnected network share, therefore cannot IMPORT a module that calls +``Path(__file__).resolve()`` at module scope. Not "misbehaves" — cannot import. + +Skill units run in host processes nobody here chose: the telegram relay, a +cron-fired lane, a hook subprocess. Those are exactly the processes most likely +to hold a dead or foreign cwd, so every module-level location in this branch +goes through here. + +STDLIB ONLY, DELIBERATELY. Importing prax would put the logger's own +construction — which reads the cwd — onto the very path this helper protects. +The fallback reports through ``sys.stderr`` instead, which is the accepted last +resort for a diagnostic that cannot afford a logger (seedgo's silent_catch +accepts a stream report). The report is deduped per path so a dead world writes +one line per module rather than burying the real traceback. +""" + +import os +import sys +from pathlib import Path + +__all__ = ["module_file"] + +# Paths already reported this process. A dead cwd is a property of the process, +# not of one import, so without this every module in the fan writes its own line. +_REPORTED: set = set() + + +def _report(raw: str, error: OSError) -> None: + """Announce a degraded resolve once per path, never raising. + + Args: + raw: The spelling being returned instead of the resolved one. + error: What resolve() raised. + """ + # The diagnostic lives inside its own protection: a world that broke + # resolve() can just as easily have closed stderr, and a fallback that + # dies while explaining itself is worse than one that stays quiet. + try: + if raw in _REPORTED: + return + _REPORTED.add(raw) + sys.stderr.write( + "[skills] resolve() unavailable for %s (%s); using the unresolved absolute spelling\n" % (raw, error) + ) + except Exception: # noqa: BLE001 - a broken stderr must not break an import + pass + + +def module_file(dunder_file: str) -> Path: + """Return a module's own path without requiring a readable cwd. + + Args: + dunder_file: The calling module's ``__file__``. + + Returns: + Path: The resolved path, or the unresolved absolute spelling when the + working directory is unreadable. Never raises OSError. + + Note: + ``__file__`` has been absolute for imported modules since Python 3.9, + so the fallback loses symlink normalisation and nothing else. That is + the whole trade: a normalised path that cannot be computed is worth + less than an un-normalised one that can. + """ + raw = str(dunder_file) + try: + return Path(raw).resolve() + except OSError as exc: + _report(raw, exc) + # abspath is NOT a second cwd read for an absolute input: posixpath + # reads the cwd only when the path is relative, and ntpath's + # _getfullpathname answers an absolute path without one. Measured + # green under the getcwd-denied world on 2026-08-31. + try: + return Path(os.path.abspath(raw)) + except OSError as abspath_exc: + # abspath was measured green under the getcwd-denied world, so + # reaching here means a world nobody has named yet. Say so — a + # second swallow would leave the caller holding a path with no + # record of how degraded it is. + _report(raw, abspath_exc) + return Path(raw) diff --git a/src/aipass/skills/apps/handlers/registry.py b/src/aipass/skills/apps/handlers/registry.py index 349928c96..81734b07c 100644 --- a/src/aipass/skills/apps/handlers/registry.py +++ b/src/aipass/skills/apps/handlers/registry.py @@ -6,6 +6,14 @@ # Modified: 2026-03-07 # ============================================= +"""Skill registry: one deduplicated catalogue built from the search paths. + +Walks project, global and built-in paths in priority order and keeps the +FIRST skill of any given name, so a project skill shadows a built-in of the +same name rather than colliding with it. Also serves single-skill and +name-only lookups off that same list. +""" + from pathlib import Path from aipass.skills.apps.handlers.json import json_handler diff --git a/src/aipass/skills/apps/handlers/template.py b/src/aipass/skills/apps/handlers/template.py index 989c2e258..c8466da32 100644 --- a/src/aipass/skills/apps/handlers/template.py +++ b/src/aipass/skills/apps/handlers/template.py @@ -6,15 +6,24 @@ # Modified: 2026-03-07 # ============================================= +"""Skill scaffolding templates: resolve a tier, copy it, fill placeholders. + +Backs ``drone @skills create``. Three tiers ship - markdown_only, +with_handler, full - and each is a real directory under ``templates/`` +copied wholesale, then rewritten in place so the new skill carries its own +name. Refuses an unknown tier rather than guessing one. +""" + import shutil from pathlib import Path from aipass.prax import logger from aipass.skills.apps.handlers.json import json_handler +from aipass.skills.apps.handlers.module_paths import module_file # Template directory lives at src/aipass/skills/templates/ -TEMPLATES_DIR = Path(__file__).resolve().parent.parent.parent / "templates" +TEMPLATES_DIR = module_file(__file__).parent.parent.parent / "templates" VALID_TYPES = ("markdown_only", "with_handler", "full") diff --git a/src/aipass/skills/apps/handlers/validator.py b/src/aipass/skills/apps/handlers/validator.py index 27fe9848d..1dc4ab573 100644 --- a/src/aipass/skills/apps/handlers/validator.py +++ b/src/aipass/skills/apps/handlers/validator.py @@ -6,6 +6,15 @@ # Modified: 2026-03-07 # ============================================= +"""Skill requirement checking: can this skill actually run here? + +Reads the ``requires`` block of a SKILL.md - pip packages, CLI binaries, +config keys - and reports each missing piece by name. Backs +``drone @skills validate``. Answers about the CURRENT machine only: a skill +that validates here may still be unrunnable elsewhere, so the result is a +measurement and never a promise. +""" + import importlib.util import os import shutil diff --git a/src/aipass/skills/apps/skills.py b/src/aipass/skills/apps/skills.py index 2169c938b..ff2aeaa81 100644 --- a/src/aipass/skills/apps/skills.py +++ b/src/aipass/skills/apps/skills.py @@ -6,23 +6,36 @@ # Modified: 2026-08-11 # ============================================= +"""Skills system entry point. + +Provides handle_command(command, args) for drone routing. +Commands: list, info, run, create, validate, --help. +""" + +import os import sys from pathlib import Path -# Prevent this script's parent dir from shadowing the 'skills' package -_script_dir = str(Path(__file__).resolve().parent) +# Prevent this script's parent dir from shadowing the 'skills' package. +# Guarded inline rather than through handlers.module_paths: this block runs +# BEFORE any aipass import by design, and importing the helper would resolve +# 'aipass' through the very shadowed path this is here to remove. +# resolve() reads the cwd on Windows (ntpath.realpath, unconditionally), so a +# process with a dead cwd could not start the CLI at all. +try: + _script_dir = str(Path(__file__).resolve().parent) +except OSError as _resolve_error: + # prax is not importable yet — this block runs before it on purpose — so + # the report goes to stderr, which is the accepted last resort for a + # diagnostic that cannot afford a logger. + sys.stderr.write(f"[skills] resolve() unavailable ({_resolve_error}); using the unresolved absolute spelling\n") + _script_dir = str(Path(os.path.abspath(__file__)).parent) if _script_dir in sys.path: sys.path.remove(_script_dir) from aipass.prax import logger # noqa: E402 from aipass.cli.apps.modules import console, error # noqa: E402 -"""Skills system entry point. - -Provides handle_command(command, args) for drone routing. -Commands: list, info, run, create, validate, --help. -""" - def print_introspection(): """Display module introspection info.""" diff --git a/src/aipass/skills/lib/telegram/apps/handlers/bot_factory.py b/src/aipass/skills/lib/telegram/apps/handlers/bot_factory.py index 3b9ee5529..89dd55ef4 100644 --- a/src/aipass/skills/lib/telegram/apps/handlers/bot_factory.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/bot_factory.py @@ -42,6 +42,7 @@ # Logging from aipass.prax import logger +from aipass.skills.apps.handlers.module_paths import module_file # JSON handler (seedgo standard) from aipass.skills.apps.handlers.json import json_handler # noqa: F401 @@ -203,7 +204,7 @@ def set_bot_commands(bot_token: str, commands: list[dict]) -> bool: def _install_service_unit() -> bool: """Copy telegram-bot@.service into ~/.config/systemd/user/ and reload.""" - UNIT_SRC = Path(__file__).resolve().parents[2] / "telegram-bot@.service" + UNIT_SRC = module_file(__file__).parents[2] / "telegram-bot@.service" UNIT_DST_DIR = Path.home() / ".config" / "systemd" / "user" UNIT_DST = UNIT_DST_DIR / "telegram-bot@.service" diff --git a/src/aipass/skills/lib/telegram/apps/handlers/bot_registry.py b/src/aipass/skills/lib/telegram/apps/handlers/bot_registry.py index 992cb16ce..80c592c73 100644 --- a/src/aipass/skills/lib/telegram/apps/handlers/bot_registry.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/bot_registry.py @@ -21,6 +21,7 @@ # Logging from aipass.prax import logger +from aipass.skills.apps.handlers.module_paths import module_file # fcntl is POSIX-only (Linux/macOS); unavailable on Windows. Guard the import and # skip advisory locking when absent. Matches the codebase convention (hooks/cadence, @@ -50,7 +51,7 @@ def _unlock(f) -> None: # CONSTANTS # ============================================= -SKILL_ROOT = Path(__file__).resolve().parents[2] +SKILL_ROOT = module_file(__file__).parents[2] REGISTRY_DIR = SKILL_ROOT / ".local" / "state" REGISTRY_FILE = REGISTRY_DIR / "_registry.json" diff --git a/src/aipass/skills/lib/telegram/apps/handlers/botfather_client.py b/src/aipass/skills/lib/telegram/apps/handlers/botfather_client.py index c434238f3..c85260433 100644 --- a/src/aipass/skills/lib/telegram/apps/handlers/botfather_client.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/botfather_client.py @@ -50,7 +50,12 @@ TELETHON_AVAILABLE = True del _telethon_check except ImportError: - logger.warning("Telethon not installed — BotFather automation unavailable") + # NOT logged here. A WARNING emitted at module import fires once per + # import, and an absent optional dependency is one condition however many + # times it is observed: ten imports produced ten identical warnings and an + # escalation. The report moved to first USE, deduped, where it names + # something a caller actually tried to do. + pass # ============================================= # CONSTANTS @@ -114,6 +119,23 @@ def _load_telethon_config() -> dict: # ============================================= +_TELETHON_WARNED = False + + +def _warn_telethon_missing_once() -> None: + """Report an absent Telethon once per process, at first use. + + The condition is a property of the environment, not of the call, so + repeating it per caller tells a reader nothing new and escalates on volume + alone. + """ + global _TELETHON_WARNED + if _TELETHON_WARNED: + return + _TELETHON_WARNED = True + logger.warning("Telethon not installed — BotFather automation unavailable") + + def check_telethon_setup() -> tuple[bool, str]: """ Check whether Telethon is ready for BotFather automation. @@ -128,6 +150,7 @@ def check_telethon_setup() -> tuple[bool, str]: (False, "reason") with a human-readable explanation of what is missing. """ if not TELETHON_AVAILABLE: + _warn_telethon_missing_once() return (False, "Telethon library not installed. Run: pip install telethon") try: diff --git a/src/aipass/skills/lib/telegram/apps/handlers/branch_plugin.py b/src/aipass/skills/lib/telegram/apps/handlers/branch_plugin.py index 40e65901f..08caaf7f5 100644 --- a/src/aipass/skills/lib/telegram/apps/handlers/branch_plugin.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/branch_plugin.py @@ -6,6 +6,13 @@ # Modified: 2026-06-29 # ============================================= +"""Per-branch Telegram bot: BaseBot with the branch stamped on every message. + +Overrides BaseBot's hooks so incoming messages carry sender attribution and +outgoing ones carry the branch name. The point is that a human reading one +Telegram thread can always tell WHICH citizen is speaking. +""" + # Standard library import argparse import sys diff --git a/src/aipass/skills/lib/telegram/apps/handlers/config.py b/src/aipass/skills/lib/telegram/apps/handlers/config.py index 22bd61354..ee37f9399 100644 --- a/src/aipass/skills/lib/telegram/apps/handlers/config.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/config.py @@ -1,3 +1,12 @@ +"""Telegram bot configuration, read from the @api secrets store. + +Every bot's token, username and allowed-user list lives in secrets and is +fetched in-process through ``aipass.api.apps.modules.secrets`` - no +subprocess, no stdout parsing, so a token never crosses a pipe. Also +validates a config before a bot is started, because a half-configured bot +that starts is worse than one that refuses. +""" + # Standard library from pathlib import PurePosixPath, PureWindowsPath from typing import Optional, List diff --git a/src/aipass/skills/lib/telegram/apps/handlers/file_handler.py b/src/aipass/skills/lib/telegram/apps/handlers/file_handler.py index c768e70d7..9a79b7be1 100644 --- a/src/aipass/skills/lib/telegram/apps/handlers/file_handler.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/file_handler.py @@ -1,3 +1,12 @@ +"""Files arriving over Telegram: download, classify, prompt, clean up. + +Pulls an attachment into a temp directory under a sanitised name, decides +whether it is readable text or opaque binary, and builds the prompt the bot +hands onward. Bounded on purpose - a size ceiling, a text-content ceiling +and an explicit extension set - because the sender chooses the file and this +branch chooses what it is willing to read. +""" + import uuid from pathlib import Path diff --git a/src/aipass/skills/lib/telegram/apps/handlers/log_streamer.py b/src/aipass/skills/lib/telegram/apps/handlers/log_streamer.py index c98ca5804..f40855e6f 100644 --- a/src/aipass/skills/lib/telegram/apps/handlers/log_streamer.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/log_streamer.py @@ -31,6 +31,7 @@ # Logging from aipass.prax import logger +from aipass.skills.apps.handlers.module_paths import module_file from aipass.skills.apps.handlers.json import json_handler # ============================================= @@ -54,7 +55,7 @@ def _get_system_logs_dir(): p = Path(test_dir) / "system" p.mkdir(parents=True, exist_ok=True) return p - here = Path(__file__).resolve() + here = module_file(__file__) for parent in here.parents: if (parent / "pyproject.toml").exists(): d = parent / "system_logs" diff --git a/src/aipass/skills/lib/telegram/handler.py b/src/aipass/skills/lib/telegram/handler.py index 3b0d35356..21ae91454 100644 --- a/src/aipass/skills/lib/telegram/handler.py +++ b/src/aipass/skills/lib/telegram/handler.py @@ -13,11 +13,11 @@ """ import sys -from pathlib import Path from aipass.prax import logger +from aipass.skills.apps.handlers.module_paths import module_file -_skill_root = Path(__file__).resolve().parent +_skill_root = module_file(__file__).parent if str(_skill_root) not in sys.path: sys.path.insert(0, str(_skill_root)) diff --git a/src/aipass/skills/tests/dead_cwd_world.py b/src/aipass/skills/tests/dead_cwd_world.py new file mode 100644 index 000000000..4f818e2ef --- /dev/null +++ b/src/aipass/skills/tests/dead_cwd_world.py @@ -0,0 +1,188 @@ +# =================== AIPass ==================== +# Name: dead_cwd_world.py +# Description: The one definition of the hostile worlds this branch's pins use +# Version: 1.1.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""The dead-cwd worlds, defined once. + +WHY THIS FILE EXISTS. Round 4 shipped four probes in this branch, each +spelling its own denial. Round 6's Python 3.10 CI leg reddened two of them and +the cause was in the SPELLING, not in the cure they were guarding: + + test_skill_discovery_survives_a_dead_cwd the project path was still offered + test_skill_creation_refuses_rather_than_guessing the refusal never happened + +Production was fine. The WORLD did not arm, so the code under test behaved +normally and the pins asserted a refusal that had no cause to occur. + +THE MECHANISM. Before 3.11, ``pathlib`` routed through ``_NormalAccessor``, +whose attributes CAPTURE their targets at class-definition time - i.e. when +``pathlib`` was first imported:: + + 3.10 Lib/pathlib.py:358 realpath = staticmethod(os.path.realpath) + 3.10 Lib/pathlib.py:1077 s = self._accessor.realpath(self, strict=strict) + 3.10 Path.cwd() -> cls(cls._accessor.getcwd()) + 3.11+ the accessor is gone; os.getcwd is called at use + +So a child that imports pathlib and THEN rebinds ``os.getcwd`` rebinds a name +nothing reads again. Patching the accessor as well makes the world +ORDER-INDEPENDENT: a probe may import pathlib before or after the denial and +gets the same hostile world either way. + +WHY ``*a, **k`` AND ``staticmethod`` BOTH. A plain function stored on a class +arrives BOUND through an instance, so ``cls._accessor.getcwd()`` passes the +accessor as ``self``. A zero-argument replacement then raises TypeError rather +than FileNotFoundError - a raise-shaped pin stays green for the wrong reason. +Measured on 3.12: either half alone cures it, so they are redundant BY +DESIGN. Recorded because whichever half a future reader deletes as obviously +covered is the one that was covering the other. + +NO 3.10 ON THIS MACHINE. Rather than record a derived row, ``ACCESSOR_SHAPE`` +below rebuilds the capture on whatever interpreter is running, so the +discrimination CI found is falsifiable here forever +(see ``TestTheAccessorTrapIsReproducibleLocally``). + +DO NOT STACK THE GETCWD AND REALPATH DENIALS. They MASK each other and the +combination is LESS hostile than the realpath denial alone: ``getabsfile`` is +called inside inspect's own ``except (TypeError, FileNotFoundError)``, so +denying getcwd makes ``abspath`` raise THERE, inspect swallows it and returns +None before the unprotected ``realpath`` in ``getmodule`` is ever reached. +Measured by @memory and independently by @prax. WORLD_A below is not an +exception to this: it denies getcwd while making realpath READ the cwd, which +is the Windows shape - it convicts ``Path.resolve()``, and it is exactly why +WORLD_B exists separately to convict ``inspect.stack()``. +""" + +__all__ = [ + "ACCESSOR_SHAPE", + "NATIVE_PATHS", + "NT_EMULATED_PLATFORM", + "POSIX_EMULATED_PLATFORM", + "WORLD_A", + "WORLD_B", + "WORLD_GETCWD_DENIED", +] + +# The denial itself. Takes any arguments so it answers correctly whether it is +# called bare (3.11+) or accessor-bound (3.10). +_DENIAL = ( + "import os\n" + "import pathlib as _pl\n" + "def _dead(*a, **k):\n" + " raise FileNotFoundError(2, 'No such file or directory (dead cwd)')\n" +) + +# World: os.getcwd() raises. Convicts Path.cwd() and any relative resolve. +WORLD_GETCWD_DENIED = _DENIAL + ( + "os.getcwd = _dead\nif hasattr(_pl, '_NormalAccessor'):\n _pl._NormalAccessor.getcwd = staticmethod(_dead)\n" +) + +# World A: take the WINDOWS reading of realpath - ntpath reads the cwd on its +# first lines unconditionally, where posixpath reads it only for a relative +# path - then deny the cwd. Convicts a raw Path.resolve() reached at import. +WORLD_A = WORLD_GETCWD_DENIED + ( + "_real_realpath = os.path.realpath\n" + "def _reads_the_cwd(p, *a, **k):\n" + " os.getcwd()\n" + " return _real_realpath(p, *a, **k)\n" + "os.path.realpath = _reads_the_cwd\n" + "if hasattr(_pl, '_NormalAccessor'):\n" + " _pl._NormalAccessor.realpath = staticmethod(_reads_the_cwd)\n" +) + +# World B: deny realpath outright and leave abspath working. Convicts +# inspect.stack() through getmodule's unguarded os.path.realpath at +# inspect.py:1009. Never combined with the getcwd denial - see the docstring. +WORLD_B = _DENIAL + ( + "os.path.realpath = _dead\n" + "if hasattr(_pl, '_NormalAccessor'):\n" + " _pl._NormalAccessor.realpath = staticmethod(_dead)\n" +) + +# --------------------------------------------------------------------------- +# Platform emulations +# --------------------------------------------------------------------------- +# Built BY NAME from the dialect module - posixpath, ntpath - and never from +# os.path, which IS the host and would emulate nothing. Only PURE STRING +# functions are borrowed (isabs, join, normpath): they consult no filesystem +# and no cwd, so the only cwd read in either body is the one written here on +# purpose. +# +# ALIASING THE DIALECT'S OWN realpath WOULD NOT WORK. ntpath.py defines +# realpath TWICE and picks at import by whether nt._getfinalpathname exists: +# off Windows it is the fallback ``def realpath(path, *, strict=False): return +# abspath(path)`` - a wrapper, not an alias, so ``ntpath.realpath is +# ntpath.abspath`` is False on 3.12.3 while behaving as abspath does. Measured +# here 2026-08-31: under a denied getcwd, ntpath.realpath(".") raises but +# ntpath.realpath("/probe") returns "\\probe" quite happily. An nt world built +# by aliasing therefore never arms for the absolute case, which is the case +# the defect is about. Write the behaviour; do not import it. + +_POSIX_SHAPED_REALPATH = ( + "import posixpath as _pp\n" + "def _posix_shaped_realpath(p, *a, **k):\n" + " # posixpath.realpath consults the cwd ONLY to complete a relative path.\n" + " if not _pp.isabs(p):\n" + " p = _pp.join(os.getcwd(), p)\n" + " return _pp.normpath(p)\n" +) + +_NT_SHAPED_REALPATH = ( + "import ntpath as _np\n" + "def _nt_shaped_realpath(p, *a, **k):\n" + " # ntpath.realpath reads the cwd unconditionally, ABOVE the isabs\n" + " # check - that ordering is the mechanism and it is what to cite.\n" + " # Measured on 3.12.3: `cwd = os.getcwd()` at Lib/ntpath.py:673 in the\n" + " # str branch (:665 for bytes), `if not had_prefix and not isabs(path)`\n" + " # at :687. The NUMBERS move between patch releases - @spawn read the\n" + " # same call at :678 - so a claim pinned to a line number fails open\n" + " # and silent on another CI leg, the way a line-scoped waiver does.\n" + " # Pin the ordering; the line is a courtesy. That read is a\n" + " # module-attribute lookup, which is why a bare os.getcwd rebind\n" + " # reaches THROUGH a captured realpath on nt and not on posix.\n" + " os.getcwd()\n" + " return _np.normpath(p)\n" +) + +# Install one dialect as the host's os.path.realpath. Use these to run a probe +# under the OPPOSITE platform and require its verdict not to move - a verdict +# that moves is the instrument importing behaviour it is not testing. +POSIX_EMULATED_PLATFORM = _POSIX_SHAPED_REALPATH + "os.path.realpath = _posix_shaped_realpath\n" +NT_EMULATED_PLATFORM = _NT_SHAPED_REALPATH + "os.path.realpath = _nt_shaped_realpath\n" + +# Dialect-native spellings for a probe. A relative path is spelled the same in +# both; an absolute one is not, and feeding posix "/probe" to the nt shape (or +# the reverse) measures the SPELLING rather than the dialect. +NATIVE_PATHS = { + "posix": {"relative": ".", "absolute": "/probe"}, + "nt": {"relative": ".", "absolute": "C:\\probe"}, +} + +# --------------------------------------------------------------------------- +# Pre-3.11 pathlib, reduced to the three lines that matter +# --------------------------------------------------------------------------- +# The capture is EAGER - at class creation - because that IS the defect; a +# lazily-read attribute would resolve the patched name and reproduce nothing. +# +# WHY realpath CAPTURES A SENTINEL AND getcwd DOES NOT. The question this +# emulation exists to answer is "did the patch reach the captured attribute", +# and the answer must not depend on the host. ``os.getcwd`` is one call with +# one meaning on every platform and it is the call under denial, so capturing +# the live one imports nothing. ``os.path.realpath`` is the dialect-divergent +# half: capture the live one and on nt the accessor reads the cwd on its own +# account, so a world that reached NOTHING still answers RAISED and the probe +# convicts the host instead of the world. That was a real CI red on the +# windows-setup leg (round 7). The sentinel returns its argument and touches +# no filesystem, no cwd and no path module, so whatever raises afterwards is +# the patch's doing on any platform. +_SENTINEL_REALPATH = "def _sentinel_realpath(p='/sentinel', *a, **k):\n return p\n" + +ACCESSOR_SHAPE = _SENTINEL_REALPATH + ( + "class _NormalAccessor:\n" + " getcwd = staticmethod(os.getcwd)\n" + " realpath = staticmethod(_sentinel_realpath)\n" + "pathlib._NormalAccessor = _NormalAccessor\n" +) diff --git a/src/aipass/skills/tests/test_dead_cwd_imports.py b/src/aipass/skills/tests/test_dead_cwd_imports.py new file mode 100644 index 000000000..6a4e20cd4 --- /dev/null +++ b/src/aipass/skills/tests/test_dead_cwd_imports.py @@ -0,0 +1,1082 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_dead_cwd_imports.py - Dead-cwd import pins +# Date: 2026-08-31 +# Version: 1.0.0 +# Category: skills/tests +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-08-31): Windows round 4 - every skills module must import +# with an unreadable working directory, in two denial worlds +# +# CODE STANDARDS: +# - Measures by RUNNING in a child process, never by grepping spellings +# ============================================= + +""" +Every skills module must import when the working directory is unreadable. + +THE DEFECT. ``ntpath.realpath`` calls ``os.getcwd()`` on its first lines +UNCONDITIONALLY - before it checks whether the path is even relative. +``posixpath.realpath`` reads the cwd only for relative paths, which is why this +was invisible on Linux for as long as it existed. ``Path.resolve()`` routes +through realpath, so on Windows every ``Path(__file__).resolve()`` REACHED AT +IMPORT is an import-time crash for a process whose directory was deleted or +whose network share dropped. Not "degrades" - cannot import. + +WHY TWO WORLDS. They convict different constructions and neither is sufficient: + + World A - take the Windows reading of realpath (call getcwd first), then deny + getcwd. Convicts a raw ``Path.resolve()`` reached at import. + It CANNOT convict ``inspect.stack()``: denying getcwd kills + abspath, so ``getmodule`` dies inside ``getabsfile`` where inspect + already catches it, and stack() completes green for the wrong + reason. + World B - deny ``os.path.realpath`` outright and leave abspath working. + Convicts ``inspect.stack()`` through getmodule's UNGUARDED + ``os.path.realpath`` at inspect.py:1009. + +WHY SUBPROCESSES. The defect is import-time. A module already in +``sys.modules`` cannot demonstrate it, and the denial has to be installed +before the first import rather than around it. + +WHY ``python -c`` AND NOT STDIN. A probe piped through stdin gets cached by +linecache under the ```` key, and the probe then lies green. The child +rides a string-pseudo frame instead. +""" + +import ast +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from dead_cwd_world import ( # noqa: E402 + ACCESSOR_SHAPE, + NATIVE_PATHS, + NT_EMULATED_PLATFORM, + POSIX_EMULATED_PLATFORM, + WORLD_A, + WORLD_B, + WORLD_GETCWD_DENIED, +) + +SRC_ROOT = Path(__file__).resolve().parents[3] +BRANCH_ROOT = Path(__file__).resolve().parents[1] +GUARD_FILE = BRANCH_ROOT / "apps" / "handlers" / "__init__.py" + + +def _skills_modules() -> list: + """Every importable skills module, by dotted name. + + Returns: + list[str]: Dotted module names, tests and templates excluded. + """ + mods = [] + for path in sorted((SRC_ROOT / "aipass" / "skills").rglob("*.py")): + rel = path.relative_to(SRC_ROOT) + if any(part.startswith(".") for part in rel.parts): + continue + if {"tests", "__pycache__", "templates"} & set(rel.parts): + continue + parts = list(rel.with_suffix("").parts) + if parts[-1] == "__init__": + parts = parts[:-1] + mods.append(".".join(parts)) + return mods + + +SKILLS_MODULES = _skills_modules() + +# The child probe. Rides a frame; each denial raises a FRESH exception +# instance - a single reused instance accumulates __traceback__ frames across +# raises and the blame it reports is then the instrument's, not the tree's. +_PROBE = r""" +import os +import os.path +import sys + +MODE = sys.argv[1] +TARGET = sys.argv[2] +CONTROL_ONLY = TARGET == "__control__" + +# Preloaded in the HEALTHY world: machinery the denial is not aimed at. Kept +# deliberately small - a preload is a claim you stop testing. +import json, linecache, importlib, inspect, pathlib # noqa +import aipass.prax # noqa - another branch, not the thing measured + + +def _denied(): + return FileNotFoundError(2, "No such file or directory (dead cwd)") + + +INJECT = os.environ.get("SKILLS_PROBE_INJECT", "1") == "1" + +# The worlds are defined ONCE, in tests/dead_cwd_world.py, and injected here as +# TEXT because the world has to exist before the module under test is imported. +# They patch pathlib's pre-3.11 _NormalAccessor as well as the module name: the +# accessor CAPTURED its copy when pathlib was first imported, so on 3.10 a bare +# module rebind patches a name nothing reads again and the world never arms. +if INJECT and MODE in ("A", "B"): + exec(os.environ["SKILLS_WORLD_TEXT"]) + +if CONTROL_ONLY: + # Does this world actually deny the call the DEFECT makes? World A must + # break Path.resolve(); world B must break inspect.stack(). A world that + # denies neither turns every pin below vacuously green. + if MODE == "A": + # ABSOLUTE, deliberately. posixpath.realpath reads the cwd for any + # RELATIVE path regardless of what is patched, so a probe using "." + # reports LIVE for the path's shape rather than for the world - it was + # doing exactly that here until 2026-08-31. + try: + pathlib.Path(os.path.abspath(os.sep)).joinpath("probe").resolve() + except OSError: + print("CONTROL_LIVE") + sys.exit(0) + elif MODE == "B": + try: + inspect.stack() + except OSError: + print("CONTROL_LIVE") + sys.exit(0) + elif MODE == "healthy": + print("CONTROL_LIVE") + sys.exit(0) + print("CONTROL_DEAD") + sys.exit(3) + +try: + __import__(TARGET) +except Exception as exc: # noqa: BLE001 - the measurement IS the exception + import traceback + + frames = traceback.extract_tb(exc.__traceback__) + ours = [f for f in frames if "/aipass/skills/" in f.filename.replace("\\", "/")] + frame = ours[-1] if ours else (frames[-1] if frames else None) + where = "unknown" + if frame is not None: + rel = frame.filename.replace("\\", "/").split("/aipass/skills/")[-1] + where = "%s:%d" % (rel, frame.lineno) + print("RED %s %s %s: %s" % (TARGET, where, type(exc).__name__, exc)) + sys.exit(1) +print("OK %s" % TARGET) +""" + + +def _run_probe(mode: str, target: str, inject: bool = True): + """Import one module in a child process under a denial world. + + Args: + mode: "healthy", "A", or "B". + target: Dotted module name, or "__control__" for the control probe. + inject: False kills the injection - the negative control for the + positive control. + + Returns: + subprocess.CompletedProcess: The child's result. + """ + env = dict(os.environ, PYTHONPATH=str(SRC_ROOT)) + env["SKILLS_PROBE_INJECT"] = "1" if inject else "0" + env["SKILLS_WORLD_TEXT"] = {"A": WORLD_A, "B": WORLD_B}.get(mode, "") + # The suite sets AIPASS_TEST_LOG_DIR, and log_streamer returns on it BEFORE + # its resolve. Inheriting it here made reverting that site survive every + # pin — a measurement that quietly stopped reaching the code it measures. + env.pop("AIPASS_TEST_LOG_DIR", None) + return subprocess.run( + [ + sys.executable, + "-c", + "import sys; exec(compile(sys.argv[3], '', 'exec'))", + mode, + target, + _PROBE, + ], + capture_output=True, + text=True, + env=env, + cwd=str(SRC_ROOT), + timeout=120, + ) + + +# --------------------------------------------------------------------------- +# Controls first: a pin whose world is not live reports nothing at all +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("mode", ["A", "B"]) +def test_denial_world_is_live(mode): + """Each world must actually deny the call the defect makes.""" + result = _run_probe(mode, "__control__") + assert "CONTROL_LIVE" in result.stdout, ( + f"world {mode} did not deny its target call; every pin below would be " + f"vacuously green. stdout={result.stdout!r} stderr={result.stderr[-400:]!r}" + ) + + +@pytest.mark.parametrize("mode", ["A", "B"]) +def test_control_can_say_no(mode): + """Kill the injection and the control must REPORT it, not pass anyway. + + The negative control for the positive control. Without this, a control + that always prints CONTROL_LIVE would certify a dead world. + """ + result = _run_probe(mode, "__control__", inject=False) + assert "CONTROL_DEAD" in result.stdout, ( + f"world {mode} control reported live with the injection removed - it " + f"cannot detect a dead world. stdout={result.stdout!r}" + ) + + +def test_healthy_world_imports_everything(): + """Baseline: with no denial, every module imports. + + A red here is a broken tree, not a dead-cwd finding, and it would make the + denial worlds unreadable. + """ + failures = [(m, _run_probe("healthy", m)) for m in SKILLS_MODULES] + red = [(m, r.stdout.strip() or r.stderr.strip()[-300:]) for m, r in failures if r.returncode != 0] + assert not red, f"modules failed to import in a HEALTHY world: {red}" + + +# --------------------------------------------------------------------------- +# The measurement +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("mode", ["A", "B"]) +def test_every_module_imports_with_a_dead_cwd(mode): + """No skills module may need a readable cwd to be imported.""" + red = [] + for module in SKILLS_MODULES: + result = _run_probe(mode, module) + if result.returncode != 0: + red.append(result.stdout.strip() or result.stderr.strip()[-300:]) + assert not red, ( + f"world {mode}: {len(red)} of {len(SKILLS_MODULES)} skills modules " + f"cannot be imported with an unreadable cwd:\n" + "\n".join(red) + ) + + +# --------------------------------------------------------------------------- +# The pin-shape hole: the deleted second walk is unreachable from any +# import-shaped test, so restoring it leaves every behavioural pin green +# --------------------------------------------------------------------------- + + +def _inspect_stack_calls(source: str) -> list: + """Find inspect.stack() calls in source, by parse tree. + + Args: + source: Python source text. + + Returns: + list[int]: Line numbers of every ``inspect.stack(...)`` call. + """ + found = [] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.Call): + continue + func = node.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "stack" + and isinstance(func.value, ast.Name) + and func.value.id == "inspect" + ): + found.append(node.lineno) + return found + + +def test_guard_contains_no_inspect_stack_call(): + """The guard must not call inspect.stack(), at any line. + + Behavioural pins CANNOT catch a restored ``inspect.stack()`` walk in the + caller-is-None branch: apps/__init__.py always supplies a real-file frame, + so that branch is unreachable from an import-shaped test and returns either + way. Measured in trigger and reproduced in hooks, drone, api and backup. + This is a parse-tree ban, not a string ban - the docstring above the walk + names inspect.stack while explaining the defect. + """ + lines = _inspect_stack_calls(GUARD_FILE.read_text(encoding="utf-8")) + assert not lines, ( + f"inspect.stack() called in {GUARD_FILE} at line(s) {lines}; it needs a " + f"readable cwd before any of the guard's own code runs (getmodule -> " + f"os.path.realpath, inspect.py:1009, outside any try)" + ) + + +def test_ast_ban_convicts_a_real_call(): + """Positive control: the ban must fire on an actual call.""" + assert _inspect_stack_calls("import inspect\nframes = inspect.stack()\n") == [2] + + +def test_ast_ban_ignores_a_docstring_mention(): + """Negative control: prose naming inspect.stack is not a call.""" + source = '"""We do not use inspect.stack() here, and here is why."""\nx = 1\n' + assert _inspect_stack_calls(source) == [] + + +def test_ast_ban_ignores_an_unrelated_stack_attribute(): + """Negative control: numpy.stack is a different function entirely.""" + source = "import numpy\narr = numpy.stack([1, 2])\n" + assert _inspect_stack_calls(source) == [] + + +# --------------------------------------------------------------------------- +# Runtime species: the audit trail's caller name +# --------------------------------------------------------------------------- + + +_CALLER_MODULE = r""" +from aipass.skills.apps.handlers.json import json_handler + + +def log_operation(): + return json_handler._get_caller_module_name() + + +def a_named_caller(): + return log_operation() +""" + +# The child denies realpath, then imports the module above FROM A +# FRAME. That frame is what convicts inspect.stack(): getsourcefile() returns +# early for a filename that exists on disk and never reaches getmodule, so a +# stack of nothing but real files does NOT need realpath. Put one pseudo-frame +# underneath and getmodule runs, hits os.path.realpath at inspect.py:1009 +# outside any try, and dies. This is why the dispatch specifies a +# string-pseudo frame: without it the pin passes over the live defect. +_CALLER_DRIVER = r""" +import os +import os.path +import sys + + +def _denied_realpath(*a, **k): + raise FileNotFoundError(2, "No such file or directory (dead cwd)") + + +os.path.realpath = _denied_realpath + +# Pre-3.11 pathlib captured its realpath at class creation, so a bare module +# rebind leaves the world inert on 3.10. Takes any arguments because a plain +# function on a class arrives BOUND - the accessor is passed as self. +import pathlib as _pl + +if hasattr(_pl, "_NormalAccessor"): + _pl._NormalAccessor.realpath = staticmethod(_denied_realpath) + +sys.path.insert(0, sys.argv[1]) +import a_named_module + +# inspect memoises filename -> module name, and a cache HIT returns before +# getmodule reaches os.path.realpath. Cold is the world a fresh process has. +import inspect + +inspect.modulesbyfile.clear() +inspect._filesbymodname.clear() + +print("CALLER=%s" % a_named_module.a_named_caller()) +""" + + +def test_caller_module_name_survives_and_still_answers(tmp_path): + """The caller name must survive a dead cwd AND still name the caller. + + Returning "unknown" for every caller also satisfies a does-not-crash + assertion, and it destroys the audit trail while doing so — so this + asserts the ANSWER, not merely the absence of a crash. + + The caller lives in a real file (a ```` frame has no module name to + report, so the pin could not tell a working answer from a degraded one), + and it is driven from a ```` frame (without a pseudo-frame in the + stack, inspect.stack() never reaches the realpath that convicts it). + """ + (tmp_path / "a_named_module.py").write_text(_CALLER_MODULE, encoding="utf-8") + env = dict(os.environ, PYTHONPATH=str(SRC_ROOT)) + env.pop("AIPASS_TEST_LOG_DIR", None) + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; exec(compile(sys.argv[2], '', 'exec'))", + str(tmp_path), + _CALLER_DRIVER, + ], + capture_output=True, + text=True, + env=env, + cwd=str(SRC_ROOT), + timeout=60, + ) + assert result.returncode == 0, f"caller-name probe crashed: {result.stderr[-800:]}" + assert "CALLER=a_named_module" in result.stdout, f"caller name degraded under a dead cwd: {result.stdout!r}" + + +# --------------------------------------------------------------------------- +# Skills-specific: the search paths a skill unit resolves at RUNTIME +# +# Skill units run in host processes this branch did not choose - the telegram +# relay, cron-fired lanes, hook subprocesses - which are exactly the processes +# likely to hold a deleted or disconnected working directory. Import-time pins +# cannot see these: both sites are inside functions. +# --------------------------------------------------------------------------- + + +_RUNTIME_PROBE = r""" +import os +import sys + +from aipass.skills.apps.handlers import creator_handler, discovery_handler + + +# THE 3.10 FAILURE lived here as a private copy of the denial. It rebound +# os.getcwd only, and before 3.11 Path.cwd() is cls(cls._accessor.getcwd()) - +# the copy the accessor CAPTURED when pathlib was first imported. The world +# never armed on that leg, production behaved normally, and both pins below +# asserted a refusal that had no cause to happen. The world now comes from the +# ONE definition in tests/dead_cwd_world.py, so it cannot drift again. +exec(os.environ["SKILLS_WORLD_TEXT"]) +import pathlib as _pl + +# An arming probe must ask the call ITS OWN world denies (@seedgo's rule). +try: + _pl.Path.cwd() +except OSError: + print("ARMED_CWD=1") +else: + print("ARMED_CWD=0") + +labels = [label for _path, label in discovery_handler.get_search_paths()] +print("SEARCH_PATHS=%s" % ",".join(labels)) + +result = creator_handler.create_skill("probe-skill", "markdown_only") +print("CREATE_SUCCESS=%s" % result["success"]) +print("CREATE_ERROR_MENTIONS_CWD=%s" % ("working directory" in (result["error"] or ""))) +""" + + +def _run_runtime_probe(): + """Exercise the cwd-derived runtime paths with getcwd denied.""" + env = dict(os.environ, PYTHONPATH=str(SRC_ROOT)) + env.pop("AIPASS_TEST_LOG_DIR", None) + env["SKILLS_WORLD_TEXT"] = WORLD_GETCWD_DENIED + return subprocess.run( + [sys.executable, "-c", "import sys; exec(compile(sys.argv[1], '', 'exec'))", _RUNTIME_PROBE], + capture_output=True, + text=True, + env=env, + cwd=str(SRC_ROOT), + timeout=60, + ) + + +def test_skill_discovery_survives_a_dead_cwd(): + """Discovery must keep serving global and builtin skills without a cwd. + + "The current project" has no answer when there is no current directory, so + that ONE search path drops out. Taking all of discovery down with it would + make every `drone @skills list/info/run` fail in the host processes most + likely to hit this. + """ + result = _run_runtime_probe() + assert result.returncode == 0, f"runtime probe crashed: {result.stderr[-800:]}" + assert "ARMED_CWD=1" in result.stdout, ( + f"Path.cwd() did not raise, so the world never armed and this pin asserts nothing: {result.stdout!r}" + ) + line = [ln for ln in result.stdout.splitlines() if ln.startswith("SEARCH_PATHS=")][0] + labels = line.split("=", 1)[1].split(",") + assert "project" not in labels, f"the un-answerable path was still offered: {labels}" + assert "global" in labels and "builtin" in labels, f"discovery lost paths it could still resolve: {labels}" + + +def test_skill_creation_refuses_rather_than_guessing(): + """Creating into "the current project" must REFUSE, not write elsewhere. + + Unlike discovery this path writes. A default target that cannot be computed + has to fail loudly: scaffolding a skill into the wrong tree is worse than + scaffolding none. + """ + result = _run_runtime_probe() + assert result.returncode == 0, f"runtime probe crashed: {result.stderr[-800:]}" + assert "ARMED_CWD=1" in result.stdout, ( + f"Path.cwd() did not raise, so the world never armed and this pin asserts nothing: {result.stdout!r}" + ) + assert "CREATE_SUCCESS=False" in result.stdout, f"creation did not refuse without a cwd: {result.stdout!r}" + assert "CREATE_ERROR_MENTIONS_CWD=True" in result.stdout, f"refusal did not name the reason: {result.stdout!r}" + + +# --------------------------------------------------------------------------- +# The caller-is-None branch, measured by BEHAVIOUR as well as by parse tree +# +# The round-4 guidance said only an AST ban can watch this branch. That was too +# strong, and spawn measured the correction: the branch is unreachable from +# IMPORT-shaped pins (apps/__init__.py always supplies a real-file frame), but +# calling _guard_branch_access() DIRECTLY from a -c child reaches it - every +# frame is then string-pseudo or importlib, both skipped, so _find_real_caller +# returns None and the branch runs. A regrown inspect.stack() walk dies there +# under a realpath denial; the cured plain return survives. +# +# The AST ban stays: it needs no subprocess and names the defect precisely. +# This is its behavioural sibling, not its replacement. +# --------------------------------------------------------------------------- + + +_NONE_BRANCH_PROBE = r""" +import os +import os.path +import sys + +import aipass.skills.apps.handlers as guard + + +def _denied_realpath(*a, **k): + raise FileNotFoundError(2, "No such file or directory (dead cwd)") + + +os.path.realpath = _denied_realpath + +# Pre-3.11 pathlib captured its realpath at class creation, so a bare module +# rebind leaves the world inert on 3.10. Takes any arguments because a plain +# function on a class arrives BOUND - the accessor is passed as self. +import pathlib as _pl + +if hasattr(_pl, "_NormalAccessor"): + _pl._NormalAccessor.realpath = staticmethod(_denied_realpath) + +# ARMING PROBE 1 - the denial actually bites the call the defect makes. A world +# spelled too realistically (running this as a script, where every frame is a +# real on-disk file and getsourcefile early-returns) leaves the denial inert +# and turns the assertion below vacuously green. +import inspect + +inspect.modulesbyfile.clear() +inspect._filesbymodname.clear() +try: + inspect.stack() +except OSError: + print("ARMED_DENIAL=1") +else: + print("ARMED_DENIAL=0") + +# ARMING PROBE 2 - we are really in the caller-is-None branch, not some other +# path that happens to return quietly. +caller_file, _line = guard._find_real_caller() +print("ARMED_NONE=%s" % (1 if caller_file is None else 0)) + +guard._guard_branch_access() +print("GUARD_RETURNED=1") +""" + + +def test_caller_is_none_branch_returns_without_a_readable_cwd(): + """Reaching the caller-is-None branch must not need a cwd. + + Both arming probes must report before the assertion means anything: a + denial that does not bite, or a stack that never produced None, would make + this pass over a live defect. + """ + env = dict(os.environ, PYTHONPATH=str(SRC_ROOT)) + env.pop("AIPASS_TEST_LOG_DIR", None) + result = subprocess.run( + [sys.executable, "-c", "import sys; exec(compile(sys.argv[1], '', 'exec'))", _NONE_BRANCH_PROBE], + capture_output=True, + text=True, + env=env, + cwd=str(SRC_ROOT), + timeout=60, + ) + assert "ARMED_DENIAL=1" in result.stdout, ( + f"the realpath denial did not bite inspect.stack(); this pin would be " + f"vacuously green. stdout={result.stdout!r} stderr={result.stderr[-400:]!r}" + ) + assert "ARMED_NONE=1" in result.stdout, ( + f"_find_real_caller did not return None, so the branch under test never ran. stdout={result.stdout!r}" + ) + assert result.returncode == 0 and "GUARD_RETURNED=1" in result.stdout, ( + f"the guard could not complete the caller-is-None branch with an unreadable cwd: {result.stderr[-800:]}" + ) + + +# --------------------------------------------------------------------------- +# Same family, different symptom: a WARNING emitted at MODULE IMPORT +# +# Ten imports across the denial worlds produced ten identical warnings about +# one condition and escalated a medic alert. An absent optional dependency is a +# property of the environment, not of the import - so it is reported once, at +# first use, where it names something a caller actually tried to do. +# --------------------------------------------------------------------------- + + +_IMPORT_WARNING_PROBE = r""" +import importlib +import sys + +records = [] + + +class _Recorder: + def warning(self, msg, *a, **k): + records.append(str(msg)) + + def __getattr__(self, _name): + return lambda *a, **k: None + + +import aipass.prax + +aipass.prax.logger = _Recorder() + +import aipass.skills.lib.telegram.apps.handlers.botfather_client as bfc + +bfc.logger = _Recorder.__call__ if False else bfc.logger +before = len(records) + +# Import the module three more times the way a fresh process would. +for _ in range(3): + del sys.modules["aipass.skills.lib.telegram.apps.handlers.botfather_client"] + mod = importlib.import_module( + "aipass.skills.lib.telegram.apps.handlers.botfather_client" + ) + mod.logger = _Recorder() + +telethon_warnings = [r for r in records if "Telethon not installed" in r] +print("IMPORT_TIME_TELETHON_WARNINGS=%d" % len(telethon_warnings)) +""" + + +def test_absent_telethon_is_not_announced_on_every_import(): + """Importing the module must not log the same environment fact each time. + + A WARNING at module scope fires once per import. Repeated across a sweep + that imports every module in several worlds, one condition became ten + identical records and an escalation - noise measured as severity. + """ + env = dict(os.environ, PYTHONPATH=str(SRC_ROOT)) + env.pop("AIPASS_TEST_LOG_DIR", None) + result = subprocess.run( + [sys.executable, "-c", "import sys; exec(compile(sys.argv[1], '', 'exec'))", _IMPORT_WARNING_PROBE], + capture_output=True, + text=True, + env=env, + cwd=str(SRC_ROOT), + timeout=60, + ) + assert result.returncode == 0, f"import-warning probe crashed: {result.stderr[-800:]}" + line = [ln for ln in result.stdout.splitlines() if ln.startswith("IMPORT_TIME_TELETHON_WARNINGS=")][0] + assert line.endswith("=0"), f"the absent-Telethon condition is still announced at import time: {line}" + + +# --------------------------------------------------------------------------- +# The accessor trap, reproduced locally +# +# There is no Python 3.10 on this machine. Rather than record a derived row - +# a CI red is a NEGATIVE measurement: it proves not-unconditionally-X, never +# which value - these rebuild pre-3.11 pathlib's capture on whatever +# interpreter is running, so the discrimination CI found stays falsifiable here +# forever. Without them, dropping the accessor patch from the shipped worlds +# would go unnoticed on 3.11+ and red again on the next 3.10 leg. +# --------------------------------------------------------------------------- + + +_EMULATION = r""" +import os +import os.path +import pathlib + +# The platform emulation is installed BEFORE the shape, so the capture below +# sees the emulated dialect. An empty string here means "this host, unchanged". +{platform} + +{shape} + +_acc = pathlib._NormalAccessor() + + +def _cwd_pre_311(): + # 3.10: Path.cwd() -> cls(cls._accessor.getcwd()) + return pathlib.Path(_acc.getcwd()) + + +def _resolve_pre_311(): + # 3.10 Lib/pathlib.py:1077 -> s = self._accessor.realpath(self, strict=...) + # A LITERAL, not os.path.abspath(os.sep) + "probe": building the argument + # through the host's path module puts host behaviour inside a probe that + # exists to measure the patch. + # + # HONEST RECORD, measured 2026-08-31 (M25): swapping the literal back for + # the host-built spelling SURVIVES the whole suite. It is an EQUIVALENT + # MUTANT, and not narrowly - the four-cell table below is flat: + # + # capture dialect literal host-built + # sentinel posix NO_RAISE NO_RAISE + # sentinel nt NO_RAISE NO_RAISE + # live posix NO_RAISE NO_RAISE + # live nt RAISED RAISED + # + # Every accessor this runs against - sentinel, denial, cwd-reading wrapper + # - answers before it looks at the path, so the argument decides nothing + # in any cell. The literal is therefore a PURITY rule and not a measured + # discriminator: it is here so that no host path module is consulted + # inside an instrument, which is the round-7 species stated as a habit. + # It would start deciding the day a world let a real realpath run to + # completion on this route. Recorded rather than scored as a kill, + # because a mutation run that quietly counts it is lying. + return _acc.realpath("/probe") + + +{world} + +_CALL = {{"cwd": _cwd_pre_311, "resolve": _resolve_pre_311}}[os.environ.get("EMU_CALL", "cwd")] + +try: + _CALL() +except FileNotFoundError: + print("VERDICT=RAISED") +except TypeError as exc: + # A zero-argument replacement bound through the accessor. The world denied + # nothing; it broke the call signature. + print("VERDICT=TYPEERROR(%s)" % exc) +else: + print("VERDICT=NO_RAISE") +""" + +_BARE_MODULE_PATCH = "def _dead(*a, **k):\n raise FileNotFoundError(2, 'dead cwd')\nos.getcwd = _dead\n" + +# A bare module patch that ALSO gives os.path.realpath the windows shape. +# Reaches nothing the accessor captured, which is exactly the point. +_BARE_WINDOWS_MODULE_PATCH = _BARE_MODULE_PATCH + ( + "_rr = os.path.realpath\n" + "def _reads(p, *a, **k):\n" + " os.getcwd()\n" + " return _rr(p, *a, **k)\n" + "os.path.realpath = _reads\n" +) + +_ZERO_ARG_ACCESSOR_PATCH = ( + "def _dead():\n" + " raise FileNotFoundError(2, 'dead cwd')\n" + "os.getcwd = _dead\n" + "pathlib._NormalAccessor.getcwd = _dead\n" +) + +# The shape as it was WRITTEN BEFORE ROUND 7 - capturing the live host +# realpath. Kept as a named constant, not as a comment, because it is the only +# thing that makes the platform slot falsifiable: it is the one shape whose +# verdict is SUPPOSED to move with the dialect. +_LIVE_CAPTURE_SHAPE = ( + "class _NormalAccessor:\n" + " getcwd = staticmethod(os.getcwd)\n" + " realpath = staticmethod(os.path.realpath)\n" + "pathlib._NormalAccessor = _NormalAccessor\n" +) + +_LAZY_SHAPE = ( + "class _NormalAccessor:\n" + " @property\n" + " def getcwd(self):\n" + " return os.getcwd\n" + " realpath = staticmethod(os.path.realpath)\n" + "pathlib._NormalAccessor = _NormalAccessor\n" +) + + +_PLATFORMS = { + "host": "", + "posix": POSIX_EMULATED_PLATFORM, + "nt": NT_EMULATED_PLATFORM, +} + + +def _run_emulation(shape: str, world: str, call: str = "cwd", platform: str = "host") -> str: + """Run the pre-3.11 accessor emulation under a given world. + + Args: + shape: Source defining ``pathlib._NormalAccessor``. + world: Source installing a denial. + call: Which pre-3.11 route to exercise - "cwd" for + ``Path.cwd() -> _accessor.getcwd()``, or "resolve" for + ``Path.resolve() -> _accessor.realpath()``. They are patched + separately and a world can arm one while leaving the other inert. + platform: "host" to leave this interpreter's path module alone, or + "posix"/"nt" to install that dialect's realpath before the shape + captures anything. Every verdict here must be identical under all + three - see ``test_every_accessor_verdict_is_platform_blind``. + + Returns: + str: The child's VERDICT line. + """ + source = _EMULATION.format(platform=_PLATFORMS[platform], shape=shape, world=world) + env = dict(os.environ, PYTHONPATH=str(SRC_ROOT)) + env.pop("AIPASS_TEST_LOG_DIR", None) + env["EMU_CALL"] = call + result = subprocess.run( + [sys.executable, "-c", "import sys; exec(compile(sys.argv[1], '', 'exec'))", source], + capture_output=True, + text=True, + env=env, + cwd=str(SRC_ROOT), + timeout=60, + ) + assert result.returncode == 0, f"emulation crashed: {result.stderr[-600:]}" + return [ln for ln in result.stdout.splitlines() if ln.startswith("VERDICT=")][0] + + +class TestTheAccessorTrapIsReproducibleLocally: + """Pin the 3.10 discrimination on whatever interpreter is running.""" + + def test_a_bare_module_patch_does_not_reach_a_captured_accessor(self): + """This is the defect: rebinding a name nothing reads again. + + The accessor took its copy of os.getcwd at class creation, so the + world reports no denial at all - and every pin underneath it would be + vacuously green. + """ + assert _run_emulation(ACCESSOR_SHAPE, _BARE_MODULE_PATCH) == "VERDICT=NO_RAISE" + + def test_the_shipped_world_does_reach_a_captured_accessor(self): + """The cure, measured against the same emulation. + + Same shape, same call, different world text - and this one arms. That + difference IS the fix; without it this pin and the one above would + agree and neither would mean anything. + """ + assert _run_emulation(ACCESSOR_SHAPE, WORLD_A) == "VERDICT=RAISED" + + def test_a_zero_argument_denial_breaks_the_call_instead_of_denying_it(self): + """A plain function on a class arrives BOUND - the accessor is self. + + Without ``*a`` or ``staticmethod`` the replacement raises TypeError, + not FileNotFoundError, so a pin that only asserts "it raised" passes + for the wrong reason. Both halves are carried deliberately; either + alone cures this, which is why neither may be deleted as redundant. + """ + verdict = _run_emulation(ACCESSOR_SHAPE, _ZERO_ARG_ACCESSOR_PATCH) + assert verdict.startswith("VERDICT=TYPEERROR"), verdict + + def test_the_emulation_itself_captures_eagerly(self): + """The published shape must capture at class creation, not at read. + + A lazily-read accessor resolves the PATCHED name and would arm under + the bare module patch - so the two pins above would stop + distinguishing an accessor patch from a module patch, and the + instrument would quietly stop measuring the thing it exists for. + """ + assert _run_emulation(_LAZY_SHAPE, _BARE_MODULE_PATCH) == "VERDICT=RAISED" + assert _run_emulation(ACCESSOR_SHAPE, _BARE_MODULE_PATCH) == "VERDICT=NO_RAISE" + + def test_the_runtime_world_also_reaches_a_captured_accessor(self): + """The getcwd world the runtime pins use, under the same emulation. + + Its consequence exists only before 3.11, so on this interpreter no + behavioural pin can tell the cured world from the uncured one. This is + where that difference is measured. + """ + assert _run_emulation(ACCESSOR_SHAPE, WORLD_GETCWD_DENIED) == "VERDICT=RAISED" + + def test_the_platform_slot_is_live(self): + """ARMING PROBE for the litmus below - and the CI red, kept local. + + A litmus that requires verdicts NOT to move is vacuously green if the + platform emulation never reaches the child: drop the slot, patch + nothing, and all three runs are the same run. So one shape must be + held whose verdict is SUPPOSED to move, and the honest choice is the + exact shape round 6 shipped - a live capture of the host realpath. + + posix leaves an absolute path alone; nt reads the cwd before looking + at it. Same world, same call, two answers - which IS the windows-setup + red of round 7, reproduced on Linux and now unable to leave silently. + """ + posix = _run_emulation(_LIVE_CAPTURE_SHAPE, _BARE_WINDOWS_MODULE_PATCH, call="resolve", platform="posix") + nt = _run_emulation(_LIVE_CAPTURE_SHAPE, _BARE_WINDOWS_MODULE_PATCH, call="resolve", platform="nt") + assert posix == "VERDICT=NO_RAISE", posix + assert nt == "VERDICT=RAISED", nt + assert posix != nt, "the platform slot reached nothing; the litmus below proves nothing" + + def test_every_accessor_verdict_is_platform_blind(self): + """THE LITMUS: no verdict in this class may move with the platform. + + This class measures whether a PATCH reached a CAPTURED attribute. That + question has nothing to do with which path dialect the host speaks, so + every probe here must answer the same under a posix realpath, an nt + realpath, and this interpreter's own - and any probe whose answer moves + is importing behaviour it is not testing. + + It cost a windows-setup red to learn: ``ACCESSOR_SHAPE`` used to + capture the LIVE ``os.path.realpath``, and on nt that reads the cwd on + its own account, so a world that reached NOTHING still answered RAISED + and the pin below convicted the host. Restore that capture and this + pin reddens here, on Linux, without waiting for CI. + """ + cases = [ + (ACCESSOR_SHAPE, _BARE_MODULE_PATCH, "cwd"), + (ACCESSOR_SHAPE, WORLD_A, "cwd"), + (ACCESSOR_SHAPE, WORLD_GETCWD_DENIED, "cwd"), + (_LAZY_SHAPE, _BARE_MODULE_PATCH, "cwd"), + (ACCESSOR_SHAPE, WORLD_A, "resolve"), + (ACCESSOR_SHAPE, _BARE_WINDOWS_MODULE_PATCH, "resolve"), + ] + moved = [] + for shape, world, call in cases: + verdicts = { + platform: _run_emulation(shape, world, call=call, platform=platform) + for platform in ("host", "posix", "nt") + } + if len(set(verdicts.values())) != 1: + moved.append((call, verdicts)) + assert not moved, f"verdicts moved with the platform: {moved}" + + +class TestTheArmingProbeIsDialectDependent: + """Why the world A arming probe uses an ABSOLUTE path - per dialect. + + Round 6 established this claim on posix and shipped it as universal, and + the windows-setup leg refused it. It is TWO rows, not one, and each is + measured here under an emulated dialect rather than derived from a CI red: + + dialect relative probe absolute probe + posix RAISED NO_RAISE + nt RAISED RAISED + + posixpath.realpath consults the cwd only to complete a RELATIVE path; + ntpath.realpath reads it on its first lines whatever it was handed. So the + relative probe raises in both dialects, and in neither does that raise + mean the world armed - it means the path had a shape. That is the whole + claim, and it is what makes an arming probe spelled "." dishonest. + + The absolute probe is where the dialects part, and the nt row is a fact + rather than a defect: ntpath's cwd read is a module-attribute lookup, so a + bare ``os.getcwd`` rebind reaches THROUGH a captured realpath on nt while + being inert on posix. The pre-3.11 capture that hid a dead world on 3.10 + Linux would not have hidden it on 3.10 Windows. + """ + + _EXPECTED = { + "posix": {"relative": "RAISED", "absolute": "NO_RAISE"}, + "nt": {"relative": "RAISED", "absolute": "RAISED"}, + } + + @staticmethod + def _probe(platform: str, path: str) -> str: + """Resolve one path under one emulated dialect, cwd denied.""" + source = ( + "import os\nimport os.path\n" + _PLATFORMS[platform] + _BARE_MODULE_PATCH + "try:\n" + " os.path.realpath(%r)\n" + " print('VERDICT=NO_RAISE')\n" + "except OSError:\n" + " print('VERDICT=RAISED')\n" % path + ) + env = dict(os.environ, PYTHONPATH=str(SRC_ROOT)) + env.pop("AIPASS_TEST_LOG_DIR", None) + result = subprocess.run( + [sys.executable, "-c", "import sys; exec(compile(sys.argv[1], '', 'exec'))", source], + capture_output=True, + text=True, + env=env, + cwd=str(SRC_ROOT), + timeout=60, + ) + assert result.returncode == 0, result.stderr[-600:] + return result.stdout.strip().split("=", 1)[1] + + @pytest.mark.parametrize("dialect", ["posix", "nt"]) + def test_a_relative_probe_raises_in_both_dialects(self, dialect): + """The load-bearing half: "." can never report a world, only a shape. + + Both dialects, same answer, and the world here armed NOTHING the + captured accessor reads - so a RAISED from "." is worth nothing as an + arming signal on any platform. + """ + assert self._probe(dialect, NATIVE_PATHS[dialect]["relative"]) == "RAISED" + + @pytest.mark.parametrize("dialect", ["posix", "nt"]) + def test_the_absolute_probe_answers_per_dialect(self, dialect): + """The half that parts, with both rows measured on this host.""" + expected = self._EXPECTED[dialect]["absolute"] + assert self._probe(dialect, NATIVE_PATHS[dialect]["absolute"]) == expected + + def test_a_dialect_probe_fed_the_other_dialect_spelling_measures_nothing(self): + """Negative control for the table: the spellings are not swappable. + + Hand posix's "/probe" to the nt shape and it still raises - but for + the shape's unconditional read, not for anything about the path. Hand + nt's "C:\\probe" to the posix shape and it raises too, because + posixpath does not read a drive letter as absolute. Either mix-up + produces an all-RAISED table that looks like agreement and measures + the SPELLING. This is why NATIVE_PATHS exists. + """ + assert self._probe("posix", NATIVE_PATHS["nt"]["absolute"]) == "RAISED" + assert self._probe("nt", NATIVE_PATHS["posix"]["absolute"]) == "RAISED" + + def test_the_live_host_agrees_with_its_own_dialect_row(self): + """The emulations are stand-ins; this is the run that licenses them. + + Drives the real thing - ``pathlib.Path(p).resolve()`` on this + interpreter, this platform, no dialect patched - and requires it to + land on the row the table claims for ``os.name``. The day a host stops + agreeing with its own row, the table is wrong rather than the host. + """ + probe = ( + "import os, pathlib\n" + _BARE_MODULE_PATCH + "def _try(p):\n" + " try:\n" + " pathlib.Path(p).resolve()\n" + " return 'NO_RAISE'\n" + " except OSError:\n" + " return 'RAISED'\n" + "print('REL=%s ABS=%s' % (_try(_REL), _try(_ABS)))\n" + ) + preamble = "import os, os.path\n_REL = '.'\n_ABS = os.path.abspath(os.sep) + 'probe'\n" + env = dict(os.environ, PYTHONPATH=str(SRC_ROOT)) + env.pop("AIPASS_TEST_LOG_DIR", None) + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; exec(compile(sys.argv[1], '', 'exec'))", + preamble + probe, + ], + capture_output=True, + text=True, + env=env, + cwd=str(SRC_ROOT), + timeout=60, + ) + assert result.returncode == 0, result.stderr[-600:] + row = self._EXPECTED[os.name if os.name in self._EXPECTED else "posix"] + assert f"REL={row['relative']}" in result.stdout, ( + f"host {os.name} disagrees with its own table row: {result.stdout!r}" + ) + assert f"ABS={row['absolute']}" in result.stdout, ( + f"host {os.name} disagrees with its own table row: {result.stdout!r}" + ) + + +class TestTheAccessorTrapResolveRoute: + """The realpath half of the capture - Path.resolve(), not Path.cwd(). + + Before 3.11 these ride DIFFERENT captured attributes, so arming one says + nothing about the other. + """ + + def test_world_a_reaches_the_captured_accessor_on_the_RESOLVE_route(self): + """WORLD_A patches two accessor attributes; this measures the second. + + ``Path.cwd()`` and ``Path.resolve()`` go through DIFFERENT captured + attributes before 3.11 - ``getcwd`` and ``realpath``. Arming one says + nothing about the other, and WORLD_A's realpath patch was unmeasured + until a mutant removed it and the whole suite stayed green. + """ + assert _run_emulation(ACCESSOR_SHAPE, WORLD_A, call="resolve") == "VERDICT=RAISED" + + def test_a_bare_module_patch_misses_the_resolve_route_too(self): + """The same discrimination on the resolve route. + + Without this the pin above could pass because the world armed for some + other reason rather than because it reached the accessor. + """ + assert _run_emulation(ACCESSOR_SHAPE, _BARE_WINDOWS_MODULE_PATCH, call="resolve") == "VERDICT=NO_RAISE" diff --git a/src/aipass/spawn/README.md b/src/aipass/spawn/README.md index 34ede4edd..8d2ffda23 100644 --- a/src/aipass/spawn/README.md +++ b/src/aipass/spawn/README.md @@ -253,7 +253,7 @@ spawn/ ├── templates/ │ ├── citizen/ # The one citizen template (50 files, 24 dirs) │ └── .archive/ # Retired templates (aipass_framework, project_agent, birthright) -├── tests/ # 27 test files, 707 tests +├── tests/ # 29 test files, 788 tests ├── spawn_json/ # JSON tracking directory ├── tools/ # Branch verification utilities ├── docs/ # Documentation @@ -314,8 +314,8 @@ except the passport heal, everything under `.ai_mail.local/` (a live mailbox is ## Tests -**764 passed | 1 skipped | 0 failed** across 27 test files (765 collected — parametrized cases expand), -measured 2026-08-28 from the repo root and from the branch directory (same tally both ways). The one skip is `test_scaffold.py`: the shipped +**869 passed | 1 skipped | 0 failed** across 29 test files (870 collected — parametrized cases expand), +measured 2026-08-31 from the repo root and from the branch directory (same tally both ways). The one skip is `test_scaffold.py`: the shipped scaffold smoke test skips by design once a branch has a real conftest (see Known Issues). | File | Focus | diff --git a/src/aipass/spawn/apps/handlers/__init__.py b/src/aipass/spawn/apps/handlers/__init__.py index 89d0657fb..e664a8f92 100644 --- a/src/aipass/spawn/apps/handlers/__init__.py +++ b/src/aipass/spawn/apps/handlers/__init__.py @@ -1,6 +1,7 @@ """Spawn handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "aipass.spawn" @@ -11,24 +12,56 @@ def _find_real_caller(): Skips this file, importlib internals, and frozen modules. Returns tuple: (file_path, import_line) or (None, None). - """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) - - for frame_info in stack: - filename = frame_info.filename - if this_file in str(Path(filename).resolve()): + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on the + Windows CI gate 2026-08-31: inspect.stack() needs a READABLE CWD, and it + needs one before any of this function's own code runs. It builds a FrameInfo + per frame, which calls getsourcefile() -> getmodule() -> os.path.realpath(); + ntpath.realpath calls os.getcwd() unconditionally on its first lines, before + it even checks whether the path is absolute, and that call site in getmodule + is not inside a try. On POSIX the equivalent raise happens earlier, inside + getabsfile(), where inspect catches it — which is why this was invisible on + Linux for as long as it existed. A frame's co_filename is already a string + in memory; reading it touches nothing. + """ + # Path.resolve() reaches the same ntpath.realpath, so this is guarded too — + # __file__ is already absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back continue - if filename.startswith("<") or "importlib" in filename: + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename + + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue + # linecache is what inspect used for code_context; called directly it + # reads one named file and returns "" rather than raising when it cannot. import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -52,10 +85,11 @@ def _guard_branch_access(): caller_file, import_line = _find_real_caller() if caller_file is None: - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return + # No caller outside this file: an interactive session, a -c script, or an + # importlib-only stack. All three are allowed. This used to walk + # inspect.stack() again looking for / and then return + # either way — a second copy of the cwd dependency above, in service of a + # branch that could not change the answer. return branch_path = "/" + MY_BRANCH.replace(".", "/") + "/" diff --git a/src/aipass/spawn/apps/handlers/adoption_ops.py b/src/aipass/spawn/apps/handlers/adoption_ops.py index 346648e42..f9739495d 100644 --- a/src/aipass/spawn/apps/handlers/adoption_ops.py +++ b/src/aipass/spawn/apps/handlers/adoption_ops.py @@ -33,7 +33,7 @@ from aipass.spawn.apps.handlers.metadata import get_branch_name, normalize_branch_name, detect_profile from aipass.spawn.apps.handlers.receipt_ops import RECEIPT_NAME, write_birth_receipt from aipass.spawn.apps.handlers.registry import ( - load_registry, + resolve_project_credential, find_registry, add_to_registry, fix_passport_registry_id, @@ -98,7 +98,7 @@ def birth_from_seed(target, seed_file, purpose, profile, registry_path): # Same mint-once ordering as the template path: the credential and the # citizen id are resolved BEFORE the passport is written, so the file and the # registry entry that follows it carry one value each, not two. - resolved_registry_id = load_registry(reg_path).get("metadata", {}).get("id", "") + resolved_registry_id = resolve_project_credential(reg_path) citizen_id = str(uuid.uuid4()) passport_path = target / ".trinity" / "passport.json" @@ -168,6 +168,13 @@ def adopt_existing(target, purpose, profile, registry_path, citizen_id=""): detected_profile = profile or detect_profile(target) reg_path = Path(registry_path) if registry_path else find_registry(target.parent) + if reg_path is None: + # Adoption registers an EXISTING citizen into an EXISTING project. With + # no registry above the target there is nothing to adopt it into, and + # choosing a location would be spawn deciding where a project begins. + msg = f"Cannot adopt {target}: no *_REGISTRY.json found above it" + logger.error("[adopt] %s", msg) + return {"branch": get_branch_name(target), "success": False, "error": msg} # Read purpose from passport if not provided if not purpose: diff --git a/src/aipass/spawn/apps/handlers/delete_ops.py b/src/aipass/spawn/apps/handlers/delete_ops.py index fbc23de2c..ddc8d22ac 100644 --- a/src/aipass/spawn/apps/handlers/delete_ops.py +++ b/src/aipass/spawn/apps/handlers/delete_ops.py @@ -120,6 +120,13 @@ def delete_branch( """ # 1. Resolve registry (needed for protection check and branch resolution) registry_path = find_registry() + if registry_path is None: + # No registry means no protection check and no entry to resolve. Refuse + # rather than proceed — a delete that cannot consult the protection + # layer is exactly the delete that must not run. + msg = "Cannot delete: no *_REGISTRY.json found above the current directory" + logger.error("[delete] %s", msg) + return _error_result(branch_name, msg) # Safety: check protected branches (hardcoded floor + registry owner + active passport) protected, reason = is_protected(branch_name, registry_path=registry_path) diff --git a/src/aipass/spawn/apps/handlers/json/json_handler.py b/src/aipass/spawn/apps/handlers/json/json_handler.py index 015809756..ac438280c 100644 --- a/src/aipass/spawn/apps/handlers/json/json_handler.py +++ b/src/aipass/spawn/apps/handlers/json/json_handler.py @@ -16,7 +16,16 @@ from aipass.aipass.shared.json_handler import JsonHandler -_SPAWN_ROOT = Path(__file__).resolve().parents[3] +# NOT resolve(): this runs at IMPORT time, and ntpath.realpath calls os.getcwd() +# unconditionally — not only for a relative path, the way posixpath does — so +# resolve() here is a cwd read that takes the whole branch down on Windows when +# the working directory is gone (Windows CI, 2026-08-31; @memory raised the +# wider species). __file__ has been absolute since 3.9, so the only thing +# resolve() added was symlink normalisation of a path that is used solely to +# build a directory for file I/O and is never compared against another path — +# a symlink resolves identically at the OS level. Guarding it with try/except +# would work too, but not needing the call is better than surviving it. +_SPAWN_ROOT = Path(__file__).parents[3] _JSON_DIR = _SPAWN_ROOT / "spawn_json" _handler = JsonHandler(json_dir=_JSON_DIR) diff --git a/src/aipass/spawn/apps/handlers/passport_migration.py b/src/aipass/spawn/apps/handlers/passport_migration.py index 7f95f81c4..8f0465a50 100644 --- a/src/aipass/spawn/apps/handlers/passport_migration.py +++ b/src/aipass/spawn/apps/handlers/passport_migration.py @@ -208,6 +208,27 @@ def repo_root() -> Path: return Path(__file__).resolve().parents[5] +def _is_hidden_path(passport: Path, base: Path) -> bool: + """True when any directory between ``base`` and ``.trinity`` starts with a dot. + + A citizen directory never begins with a dot, so this is a rule rather than a + blocklist of names — ``.archive``, ``.backup``, ``.git`` and whatever the + next retirement convention is are all refused by the same sentence, and no + real citizen is ever caught by it. ``.trinity`` itself is excluded because it + is part of the shape being matched, not part of the location. + """ + try: + relative = passport.relative_to(base) + except ValueError: + # Not under the scanned root at all. Both globs are anchored at base, so + # this is unreachable today — but a refusal nobody can see is how a + # future caller passing an unanchored path gets silently migrated. + logger.warning(f"[spawn] Passport outside the scanned root, refused: {passport}") + return True + # Drop the trailing ".trinity/passport.json" the globs both end with. + return any(part.startswith(".") for part in relative.parts[:-2]) + + def discover_passports(root: Path | str | None = None) -> list[PassportTarget]: """Glob every live fleet passport under ``root``. @@ -217,7 +238,14 @@ def discover_passports(root: Path | str | None = None) -> list[PassportTarget]: is precisely the thing being corrected. Backups (``.backup/``), archives and the template under - ``spawn/templates/`` never match either glob, so they cannot be swept in. + ``spawn/templates/`` are refused BY RULE — ``_is_hidden_path`` drops any + candidate with a dotted directory component between the root and + ``.trinity``. That used to be true only by luck of layout: pathlib's ``*`` + matches dotted names, so ``src/aipass/.archive/.trinity/passport.json`` was + a match waiting for such a directory to exist (measured 2026-08-31). + Vera-Studio holds five such passports today under ``src/.archive/``, which + is what the rule exists for: retiring a citizen is a REGISTRY act, and a + passport walk cannot tell a retired citizen from a live one. Args: root: Repo root to scan. Defaults to the live repo root. @@ -229,6 +257,8 @@ def discover_passports(root: Path | str | None = None) -> list[PassportTarget]: targets: list[PassportTarget] = [] for match in sorted(base.glob(CORE_GLOB)): + if _is_hidden_path(match, base): + continue targets.append( PassportTarget( path=match, @@ -238,6 +268,8 @@ def discover_passports(root: Path | str | None = None) -> list[PassportTarget]: ) ) for match in sorted(base.glob(RESIDENT_GLOB)): + if _is_hidden_path(match, base): + continue # projects//src///.trinity/passport.json project_root = match.parents[4] targets.append( diff --git a/src/aipass/spawn/apps/handlers/placeholders.py b/src/aipass/spawn/apps/handlers/placeholders.py index 585f2106b..7b4e48683 100644 --- a/src/aipass/spawn/apps/handlers/placeholders.py +++ b/src/aipass/spawn/apps/handlers/placeholders.py @@ -97,6 +97,13 @@ def resolve_relative_path(target_dir, registry_path=None) -> str: return target.relative_to(projects_root / parts[0]).as_posix() reg_path = Path(registry_path) if registry_path else find_registry(start_path=target.parent) + if reg_path is None: + # find_registry answers absence with None now (@aipass, 2026-08-31). + # There is no project root to make the path relative TO, which is the + # same outcome the ValueError arm already handles: fall back to the bare + # name rather than inventing an anchor. + logger.warning("[spawn] No registry found above %s — passport path falls back to the bare name", target) + return target.name try: return target.relative_to(Path(reg_path).resolve().parent).as_posix() except ValueError: @@ -140,7 +147,10 @@ def build_replacements_dict(target_dir, branch_name, **overrides): registry_path = ( Path(caller_registry_path) if caller_registry_path else find_registry(start_path=Path(target_dir).parent) ) - if registry_path.exists(): + # None means no registry above the target at all; absent and unfindable + # are the same answer here — leave registry_id empty rather than + # substituting a credential nobody issued. + if registry_path is not None and registry_path.exists(): data = json.loads(registry_path.read_text(encoding="utf-8")) registry_id = data.get("metadata", {}).get("id", "") diff --git a/src/aipass/spawn/apps/handlers/registry.py b/src/aipass/spawn/apps/handlers/registry.py index b55a7af32..a0a4a6e5d 100644 --- a/src/aipass/spawn/apps/handlers/registry.py +++ b/src/aipass/spawn/apps/handlers/registry.py @@ -76,6 +76,14 @@ def is_protected(branch_name, branch_dir=None, registry_path=None): try: rp = Path(registry_path) if registry_path else find_registry() + if rp is None: + # No registry anywhere above us. The hardcoded floor above has + # already had its say, so the honest answer is "this layer cannot + # protect it" — not a crash. AttributeError would escape the except + # below (OSError/ValueError/KeyError only) and turn "no registry + # here" into a traceback inside a SAFETY check. + logger.info("[is_protected] No registry found — registry layer cannot answer for %s", branch_name) + return False, "no registry found — not protected by the registry layer" reg_data = load_registry(rp) for entry in branches_as_list(reg_data.get("branches", [])): if entry.get("name", "").lower() == name_lower: @@ -157,30 +165,44 @@ def _default_registry_schema(credential=""): def load_registry(registry_path): """ - Load registry from JSON file. Returns empty schema if missing. - - A registry that does not exist yet is a NEW PROJECT, and it is born with a - freshly minted ``metadata.id`` — the project credential every passport in - that project carries as ``citizenship.registry_id`` (rendered as - "Branch reg no."). Without it the project's first citizen falls back to - whatever registry discovery finds next, which is AIPass's own id: a - brand-new agent displaying a number from a project it was never part of. - - The unreadable case deliberately does NOT mint one. A file that exists but - cannot be parsed is not a new project — it is a project whose credential we - failed to read, and inventing a replacement would re-credential a live - project and orphan every passport already carrying the real id. Missing - means regenerate; unreadable means do not clobber. + Load registry from JSON file. Returns an empty schema if missing — never a + minted identity. + + LOADING IS A READ. This function used to mint a fresh ``metadata.id`` for a + file that did not exist, on the reasoning that a missing registry IS a new + project. That reasoning was right about the defect and wrong about the + place. The shared resolver ``registry_discovery.find_registry`` returns + ``Path.cwd() / AIPASS_REGISTRY.json`` when nothing exists — a PATH for an + ABSENCE — so ``save_registry(p, load_registry(p))`` wrote a registry + carrying a brand-new project credential into whatever directory the caller + happened to be standing in (@memory, measured 2026-08-31). Nobody composed + it that way, but a load that invents a trust-anchor identity for a file that + is not there is a mint, and minting is a decision, not a fallback. + + The decision now lives in ``resolve_project_credential``, which the two real + create sites call by name. The asymmetry the credential tests pin survives + intact, one layer over: absent means mint (when a creator asks for it), + unreadable never does — a file that exists but cannot be parsed is a project + whose credential we failed to read, and inventing a replacement would + re-credential a live project and orphan every passport carrying the real id. Args: registry_path: Path to AIPASS_REGISTRY.json Returns: - Dict with metadata and branches list + Dict with metadata and branches list. No ``metadata.id`` unless the file + itself carried one. """ registry_path = Path(registry_path) if not registry_path.exists(): - return _default_registry_schema(credential=str(uuid.uuid4())) + # Said out loud. A silent empty document is exactly how the mint stayed + # invisible for as long as it did. + logger.info( + "[registry] %s does not exist — returning an empty document with NO credential. " + "A project credential is minted only by resolve_project_credential(), never by a load.", + registry_path, + ) + return _default_registry_schema() data = json_handler.read_json(registry_path) if data is not None: @@ -194,6 +216,42 @@ def load_registry(registry_path): return _default_registry_schema() +def resolve_project_credential(registry_path) -> str: + """Return the project credential to stamp on a citizen being created here. + + This is the explicit mint — the counterpart to ``load_registry`` no longer + doing it. Only a caller that KNOWS it is creating a citizen should call it, + because for a registry that does not exist yet the honest answer is "this is + a new project, here is its identity", and that sentence is only true when + somebody is actually founding one. + + Three cases, and the middle one is the whole point of the split: + + * registry ABSENT -> mint a fresh UUID. Without it the project's first + citizen falls back to whatever registry discovery finds next, which is + AIPass's own id: a brand-new agent displaying a number from a project it + was never part of. + * registry PRESENT -> hand back its own ``metadata.id``, untouched. + The project's lock is never re-issued. + * registry UNREADABLE -> return "". Not absent, so not a new project; + inventing one here would orphan every passport already carrying the real + id. + + Mints a value; writes nothing. Creating the file is the create step's job. + + Args: + registry_path: Path to the project's ``*_REGISTRY.json``. + + Returns: + The credential string, or "" when it cannot be established. + """ + registry_path = Path(registry_path) + if not registry_path.exists(): + return str(uuid.uuid4()) + + return load_registry(registry_path).get("metadata", {}).get("id", "") or "" + + def save_registry(registry_path, data): """ Save registry to JSON file. Auto-updates timestamp and sorts branches. @@ -506,6 +564,11 @@ def ensure_admin(registry_path=None, branch_name=ADMIN_BRANCH): return "refused", reason registry_path = Path(registry_path) if registry_path else find_registry() + if registry_path is None: + reason = "REFUSED: no registry found — admin is granted in a registry, and there is none to grant it in" + logger.warning("[registry] %s", reason) + return "refused", reason + reg_data = load_registry(registry_path) branches = branches_as_list(reg_data.get("branches", [])) @@ -571,7 +634,10 @@ def get_owner(start_path=None): Walks up from start_path (default CWD) to find *_REGISTRY.json. """ registry_path = find_registry(start_path=start_path) - if not registry_path.exists(): + if registry_path is None or not registry_path.exists(): + # find_registry answers absence with None now (@aipass, 2026-08-31). + # No registry and an unwritten registry are the same answer here: nobody + # owns a project that does not exist. return None reg_data = load_registry(registry_path) for branch in branches_as_list(reg_data.get("branches", [])): diff --git a/src/aipass/spawn/apps/handlers/repair_ops.py b/src/aipass/spawn/apps/handlers/repair_ops.py index 132c8fbdd..1142a5f06 100644 --- a/src/aipass/spawn/apps/handlers/repair_ops.py +++ b/src/aipass/spawn/apps/handlers/repair_ops.py @@ -32,6 +32,40 @@ from aipass.spawn.apps.handlers.json import json_handler +# ============================================================================= +# REGISTRY DISCOVERY +# ============================================================================= + +REGISTRY_SUFFIX = "_REGISTRY.json" + + +def _registry_in(root): + """Return the fleet registry directly inside ``root``, or None. + + Filters the glob's results by a case-SENSITIVE suffix check, because the + glob alone is not a filter everywhere it runs. On Windows (and on macOS's + default case-insensitive volumes) ``glob("*_REGISTRY.json")`` also returns + lowercase ``*_registry.json`` files — and ``pathlib``'s ``*`` matches dotted + names, unlike a shell glob. Every branch in the fleet carries + ``.spawn/.template_registry.json`` and ten ``flow_json`` plan counters, and + a dotted name sorts FIRST, so on a case-insensitive volume this lane could + hand a template's file-hash counter to the code that repairs registries. + Reported by @drone via @devpulse from the round-2 Windows gate, 2026-08-31. + + The check is on the SUFFIX only, never the stem: external projects name + their registry after themselves and nothing promises an uppercase stem. + + Picking the first of the sorted matches is inherited behaviour, restated + here rather than decided here — one implementation for both call sites so + they cannot drift apart, which is the only claim this extraction makes. + """ + root = Path(root) + for candidate in sorted(root.glob("*" + REGISTRY_SUFFIX)): + if candidate.name.endswith(REGISTRY_SUFFIX): + return candidate + return None + + # ============================================================================= # REGISTRY PATH UPDATE # ============================================================================= @@ -208,6 +242,10 @@ def move_branch(branch_name, new_path, registry_path=None, dry_run=False, reloca """ if registry_path is None: registry_path = find_registry() + if registry_path is None: + msg = "No *_REGISTRY.json found above the current directory — nothing to relocate against" + logger.error("[repair] %s", msg) + return {"success": False, "error": msg, "moved": False, "registry_updated": False} registry_path = Path(registry_path) project_root = registry_path.parent @@ -358,10 +396,7 @@ def detect_pollution(project_root): issues = [] project_name = project_root.name - registry_path = None - for f in sorted(project_root.glob("*_REGISTRY.json")): - registry_path = f - break + registry_path = _registry_in(project_root) nested = project_root / project_name if nested.is_dir(): @@ -479,10 +514,7 @@ def repair_project(project_path, dry_run=False): if not project_path.is_dir(): return {"success": False, "error": f"Project path does not exist: {project_path}"} - registry_path = None - for f in sorted(project_path.glob("*_REGISTRY.json")): - registry_path = f - break + registry_path = _registry_in(project_path) if registry_path is None: return {"success": False, "error": f"No *_REGISTRY.json found in {project_path}"} diff --git a/src/aipass/spawn/apps/handlers/sync_registry_ops.py b/src/aipass/spawn/apps/handlers/sync_registry_ops.py index 2919756a6..d91c3ab4f 100644 --- a/src/aipass/spawn/apps/handlers/sync_registry_ops.py +++ b/src/aipass/spawn/apps/handlers/sync_registry_ops.py @@ -178,6 +178,22 @@ def sync_registry(fix: bool = False) -> dict: """ # 1. Load registry — CWD-aware discovery registry_path = find_registry() + if registry_path is None: + # find_registry answers absence with None now (@aipass, 2026-08-31) — + # a path for an absence was a lie with a type signature. There is + # nothing to synchronise against, and inventing a project root here is + # how a registry gets written somewhere nobody asked for. + logger.error("[sync-registry] No *_REGISTRY.json found above the current directory — nothing to sync") + return { + "error": "no registry found above the current directory", + "stale": [], + "unregistered": [], + "healthy": [], + "fixed": [], + "spawn_rebuilt": [], + "ids_fixed": [], + "descriptions_backfilled": [], + } project_root = registry_path.parent registry = load_registry(registry_path) branches = branches_as_list(registry.get("branches", [])) @@ -410,6 +426,10 @@ def check_owner_identity(registry_path=None): """ if registry_path is None: registry_path = find_registry() + if registry_path is None: + reason = "no *_REGISTRY.json found above the current directory" + logger.error("[registry-check] %s", reason) + return {"clean": False, "owner": None, "owner_uid": "", "issues": [{"flag": "no_registry", "detail": reason}]} registry_path = Path(registry_path) reg_data = load_registry(registry_path) branches = branches_as_list(reg_data.get("branches", [])) @@ -554,6 +574,10 @@ def fix_owner_identity(registry_path=None, dry_run=False): """ if registry_path is None: registry_path = find_registry() + if registry_path is None: + reason = "no *_REGISTRY.json found above the current directory" + logger.error("[registry-check] %s", reason) + return {"clean": False, "owner": None, "owner_uid": "", "issues": [{"flag": "no_registry", "detail": reason}]} registry_path = Path(registry_path) reg_data = load_registry(registry_path) branches = branches_as_list(reg_data.get("branches", [])) diff --git a/src/aipass/spawn/apps/handlers/update_ops.py b/src/aipass/spawn/apps/handlers/update_ops.py index 4ff0e7830..ae72820a1 100644 --- a/src/aipass/spawn/apps/handlers/update_ops.py +++ b/src/aipass/spawn/apps/handlers/update_ops.py @@ -247,6 +247,9 @@ def update_all(dry_run: bool = False, trace: bool = False, citizen_class: str | Returns list of result dicts. """ registry_path = find_registry() + if registry_path is None: + logger.error("[update] No *_REGISTRY.json found above the current directory — no branches to update") + return [] registry = load_registry(registry_path) branches = branches_as_list(registry.get("branches", [])) @@ -335,6 +338,9 @@ def _read_citizen_class(branch_dir: Path) -> str: def _resolve_branch_path(branch_name: str) -> Path | None: """Resolve a branch name to its absolute directory path via the registry.""" registry_path = find_registry() + if registry_path is None: + logger.info("[update] No *_REGISTRY.json found — cannot resolve a branch path without one") + return None project_root = registry_path.parent registry = load_registry(registry_path) diff --git a/src/aipass/spawn/apps/modules/core.py b/src/aipass/spawn/apps/modules/core.py index eb6d188e1..429aa8827 100644 --- a/src/aipass/spawn/apps/modules/core.py +++ b/src/aipass/spawn/apps/modules/core.py @@ -27,7 +27,11 @@ try: from aipass.cli.apps.modules.display import console -except ImportError as e: +# An optional dependency's fallback has to be at least as wide as the failures its import can produce. +# A peer's handler package does real filesystem work at import time (its access guard), so a broken peer can raise OSError — FileNotFoundError from a dead cwd — not only ImportError. +# Catching ImportError alone means 'the peer is unavailable' is handled and 'the peer is broken' is fatal, which is backwards. +# Raised by @prax 2026-08-31 from their own watcher, measured against spawn the same hour. +except (ImportError, OSError) as e: logger.warning("Failed to import aipass.cli.apps.modules.display, falling back to rich.console: %s", e) from rich.console import Console @@ -45,7 +49,7 @@ from aipass.spawn.apps.handlers.mint_verify import verify_mint from aipass.spawn.apps.handlers.receipt_ops import write_birth_receipt from aipass.spawn.apps.handlers.registry import ( - load_registry, + resolve_project_credential, find_registry, add_to_registry, get_next_citizen_number, @@ -95,8 +99,9 @@ def _load_meta_tabs(): """ try: from aipass.memory.apps.handlers.tracking.tab_renderer import render_all_meta_tabs - except ImportError: - logger.info("[spawn] @memory not available — meta-tab placeholders will be empty") + # Width, not politeness: see the module-level import above. + except (ImportError, OSError) as e: + logger.info("[spawn] @memory not available — meta-tab placeholders will be empty (%s)", e) return {} tabs = render_all_meta_tabs() @@ -310,6 +315,13 @@ def _spawn_agent( # Determine registry — per-project, never borrow another project's reg_path = Path(registry_path) if registry_path else find_registry(target.parent) + if reg_path is None: + # No registry above the target. That is exactly the "outside any known + # registry" case the ValueError arm below already resolves, so take the + # same road rather than crashing on None (@aipass changed find_registry + # to answer absence with None on 2026-08-31). + logger.info("[spawn] No registry above %s — resolving project-local registry", target) + reg_path = _find_project_registry(target) try: target.relative_to(reg_path.parent) except ValueError: @@ -318,15 +330,19 @@ def _spawn_agent( citizen_number = get_next_citizen_number(reg_path) # Resolve the PROJECT credential (the registry's own metadata.id) for the - # passport's citizenship.registry_id. load_registry mints one when the - # registry does not exist yet, which is what a brand-new external project - # is — resolving it HERE rather than at registration time is the whole - # point: the passport is written at step 1 and the registry at step 4, so - # reading it later would stamp the passport with a credential that had not - # been minted yet and fall back to AIPass's own id. Same mint-once ordering - # as citizen_id below; the value is handed to add_to_registry so the file - # that eventually lands carries the id the passport already claims. - resolved_registry_id = load_registry(reg_path).get("metadata", {}).get("id", "") + # passport's citizenship.registry_id. resolve_project_credential mints one + # when the registry does not exist yet, which is what a brand-new external + # project is — resolving it HERE rather than at registration time is the + # whole point: the passport is written at step 1 and the registry at step 4, + # so reading it later would stamp the passport with a credential that had + # not been minted yet and fall back to AIPass's own id. Same mint-once + # ordering as citizen_id below; the value is handed to add_to_registry so + # the file that eventually lands carries the id the passport already claims. + # + # The mint is asked for BY NAME. It used to be a side effect of loading a + # path that did not exist, which meant every reader of a missing registry + # minted one too (@memory, 2026-08-31). This site is a creator and says so. + resolved_registry_id = resolve_project_credential(reg_path) # Mint the citizen's own unique id ONCE, here, so the passport and the # registry entry carry the same value. Minting it inside add_to_registry diff --git a/src/aipass/spawn/apps/modules/migrate_passports.py b/src/aipass/spawn/apps/modules/migrate_passports.py index 7a71b8bd6..cdfd4c8c4 100644 --- a/src/aipass/spawn/apps/modules/migrate_passports.py +++ b/src/aipass/spawn/apps/modules/migrate_passports.py @@ -209,6 +209,18 @@ def _print_receipt(receipt: dict) -> None: console.print() if not receipt["confirm"] and receipt["changed"]: console.print(" [dim]No files were written. Add --confirm to execute.[/dim]") + elif not receipt["scanned"]: + # Zero scanned is not an all-clear. discover_passports globs + # src/aipass/*/ and projects/*/src/*/*/ — both shaped like THIS + # repository — so any root that is not an AIPass checkout yields no + # targets, and the green line below would call that success. Measured + # against a real sibling repo (@wren, schema 1.0, untouched): the + # command reported "every scanned passport is already 2.0". + warning(f"No passports found under {receipt['root']} — nothing was scanned.") + console.print( + " [dim]Discovery matches this repository's layout " + "(src/aipass// and projects//src///).[/dim]" + ) elif not receipt["changed"]: console.print(" [green]Nothing to migrate — every scanned passport is already 2.0.[/green]") console.print() diff --git a/src/aipass/spawn/templates/citizen/.spawn/.template_registry.json b/src/aipass/spawn/templates/citizen/.spawn/.template_registry.json index 636910c70..0ce0857fd 100644 --- a/src/aipass/spawn/templates/citizen/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/citizen/.spawn/.template_registry.json @@ -249,7 +249,7 @@ "path": "apps/handlers/README.md" }, "f022": { - "content_hash": "dbfc0e044461", + "content_hash": "081f1621005e", "has_branch_placeholder": false, "name": "__init__.py", "path": "apps/handlers/__init__.py" @@ -279,7 +279,7 @@ "path": "__init__.py" }, "f027": { - "content_hash": "e17341c51e86", + "content_hash": "3f94fa075d1f", "has_branch_placeholder": true, "name": "{{BRANCH}}.py", "path": "apps/{{BRANCH}}.py" @@ -399,7 +399,7 @@ "path": "apps/handlers/json/__init__.py" }, "f047": { - "content_hash": "afed9ec00a56", + "content_hash": "699a7c1684a1", "has_branch_placeholder": false, "name": "json_handler.py", "path": "apps/handlers/json/json_handler.py" @@ -411,7 +411,7 @@ "path": "tests/test_cli_routing.py" }, "f049": { - "content_hash": "4b01d4130807", + "content_hash": "8b5cac768340", "has_branch_placeholder": false, "name": "test_scaffold.py", "path": "tests/test_scaffold.py" @@ -425,7 +425,7 @@ }, "metadata": { "description": "Template file tracking registry for ID-based updates", - "last_updated": "2026-08-28", + "last_updated": "2026-08-31", "version": "1.0.0" } } diff --git a/src/aipass/spawn/templates/citizen/apps/handlers/__init__.py b/src/aipass/spawn/templates/citizen/apps/handlers/__init__.py index c984b894c..8885f6161 100644 --- a/src/aipass/spawn/templates/citizen/apps/handlers/__init__.py +++ b/src/aipass/spawn/templates/citizen/apps/handlers/__init__.py @@ -1,6 +1,7 @@ """{{BRANCHNAME}} handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "aipass.{{BRANCH}}" @@ -11,24 +12,56 @@ def _find_real_caller(): Skips this file, importlib internals, and frozen modules. Returns tuple: (file_path, import_line) or (None, None). - """ - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) - - for frame_info in stack: - filename = frame_info.filename - if this_file in str(Path(filename).resolve()): + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on the + Windows CI gate 2026-08-31: inspect.stack() needs a READABLE CWD, and it + needs one before any of this function's own code runs. It builds a FrameInfo + per frame, which calls getsourcefile() -> getmodule() -> os.path.realpath(); + ntpath.realpath calls os.getcwd() unconditionally on its first lines, before + it even checks whether the path is absolute, and that call site in getmodule + is not inside a try. On POSIX the equivalent raise happens earlier, inside + getabsfile(), where inspect catches it — which is why this was invisible on + Linux for as long as it existed. A frame's co_filename is already a string + in memory; reading it touches nothing. + """ + # Path.resolve() reaches the same ntpath.realpath, so this is guarded too — + # __file__ is already absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. + if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back continue - if filename.startswith("<") or "importlib" in filename: + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing the import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename + + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue + # linecache is what inspect used for code_context; called directly it + # reads one named file and returns "" rather than raising when it cannot. import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None - return str(Path(filename).resolve()), import_line + return resolved, import_line return None, None @@ -52,10 +85,11 @@ def _guard_branch_access(): caller_file, import_line = _find_real_caller() if caller_file is None: - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return + # No caller outside this file: an interactive session, a -c script, or an + # importlib-only stack. All three are allowed. This used to walk + # inspect.stack() again looking for / and then return + # either way — a second copy of the cwd dependency above, in service of a + # branch that could not change the answer. return branch_path = "/" + MY_BRANCH.replace(".", "/") + "/" diff --git a/src/aipass/spawn/templates/citizen/apps/handlers/json/json_handler.py b/src/aipass/spawn/templates/citizen/apps/handlers/json/json_handler.py index 5427d4ee3..6452a41fd 100644 --- a/src/aipass/spawn/templates/citizen/apps/handlers/json/json_handler.py +++ b/src/aipass/spawn/templates/citizen/apps/handlers/json/json_handler.py @@ -23,7 +23,16 @@ from aipass.aipass.shared.json_handler import JsonHandler -_BRANCH_ROOT = Path(__file__).resolve().parents[3] +# NOT resolve(): this runs at IMPORT time, and ntpath.realpath calls os.getcwd() +# unconditionally — not only for a relative path, the way posixpath does — so +# resolve() here is a cwd read that takes the whole branch down on Windows when +# the working directory is gone (Windows CI, 2026-08-31; @memory raised the +# wider species). __file__ has been absolute since 3.9, so the only thing +# resolve() added was symlink normalisation of a path that is used solely to +# build a directory for file I/O and is never compared against another path — +# a symlink resolves identically at the OS level. Guarding it with try/except +# would work too, but not needing the call is better than surviving it. +_BRANCH_ROOT = Path(__file__).parents[3] _JSON_DIR = _BRANCH_ROOT / "{{BRANCH}}_json" _handler = JsonHandler(json_dir=_JSON_DIR) diff --git a/src/aipass/spawn/templates/citizen/apps/{{BRANCH}}.py b/src/aipass/spawn/templates/citizen/apps/{{BRANCH}}.py index 4f599869c..d07be2bbb 100644 --- a/src/aipass/spawn/templates/citizen/apps/{{BRANCH}}.py +++ b/src/aipass/spawn/templates/citizen/apps/{{BRANCH}}.py @@ -61,9 +61,13 @@ def _module_import_path(stem: str) -> str: try: importlib.import_module(f"aipass.{{BRANCH}}.apps.modules.{stem}") return f"aipass.{{BRANCH}}.apps.modules.{stem}" - except ImportError as e: + except (ImportError, OSError) as e: # Not silent: the fallback path is a real branch in behaviour, so the # reason it was taken has to stay readable afterwards. + # OSError as well as ImportError: the package import runs the handler + # access guard, which touches the filesystem, so an unreadable cwd + # raises FileNotFoundError here — and the local layout is exactly the + # right answer in that world too. logger.info("[{{BRANCHNAME}}] Package import failed for %s (%s) - using local layout", stem, e) return f"apps.modules.{stem}" diff --git a/src/aipass/spawn/templates/citizen/tests/test_scaffold.py b/src/aipass/spawn/templates/citizen/tests/test_scaffold.py index 193b3bb64..582f21c3b 100644 --- a/src/aipass/spawn/templates/citizen/tests/test_scaffold.py +++ b/src/aipass/spawn/templates/citizen/tests/test_scaffold.py @@ -1,12 +1,20 @@ # =================== META ==================== # Name: test_scaffold.py # Description: Scaffold smoke test for template test infrastructure -# Version: 1.1.0 +# Version: 1.2.0 # Created: 2026-07-04 -# Modified: 2026-07-27 +# Modified: 2026-08-31 # ============================================= -"""Scaffold smoke test — proves pytest infrastructure works in this branch.""" +"""Scaffold smoke test — proves pytest infrastructure works in this branch. + +Also carries the one structural pin a citizen is BORN with: the handler +access guard must never walk ``inspect.stack()``. See the test below for why +that pin lives here rather than in a branch's own suite. +""" + +import ast +from pathlib import Path import pytest @@ -25,3 +33,97 @@ def test_conftest_fixtures_available(request): pytest.skip("branch conftest replaced the template scaffold fixtures — real suite covers this") assert temp_test_dir.exists() assert isinstance(sample_test_data, dict) + + +def _inspect_stack_calls(source: str) -> list: + """Line numbers of every ``inspect.stack()`` CALL in ``source``. + + An AST matcher, not a string search: the guard's docstring names + ``inspect.stack()`` while explaining what it replaced, and a spelling ban + would convict the explanation. + """ + tree = ast.parse(source) + + module_aliases = {"inspect"} + direct_names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "inspect": + module_aliases.add(alias.asname or alias.name) + elif isinstance(node, ast.ImportFrom) and node.module == "inspect": + for alias in node.names: + if alias.name == "stack": + direct_names.add(alias.asname or alias.name) + + found = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "stack" + and isinstance(func.value, ast.Name) + and func.value.id in module_aliases + ): + found.append(node.lineno) + elif isinstance(func, ast.Name) and func.id in direct_names: + found.append(node.lineno) + + return sorted(found) + + +def test_the_handler_guard_never_walks_inspect_stack(): + """This branch's import guard must not call ``inspect.stack()``. + + WHY A NEWBORN IS BORN WITH THIS PIN. ``inspect.stack()`` builds a FrameInfo + for every frame, which resolves that frame's source file + (getsourcefile -> getmodule -> os.path.realpath), and ``ntpath.realpath`` + computes ``os.getcwd()`` unconditionally — before it even checks whether the + path is absolute. So on Windows, in a process whose working directory is + gone or unreadable, importing ANY handler in this branch dies inside the + stdlib, one line above the guard's own first statement. Found on the Windows + CI gate 2026-08-31; the cure is to walk frames with ``sys._getframe`` and + read ``frame.f_code.co_filename``, which asks the filesystem nothing. + + WHY IT IS STRUCTURAL. The regression has no behavioural instrument. The + guard's ``caller_file is None`` branch is unreachable from any import-shaped + test, because ``apps/__init__.py`` always supplies a real-file frame — + measured identically across nine branches, where restoring the walk left the + whole suite green. A call that must not exist is pinned by reading the + source; there is nothing else to watch. + + Unlike the fixture smoke test above, this does NOT skip when a branch grows + its own suite: the guard is inherited code that a branch rarely edits, which + is exactly why nobody would notice it changing. + """ + guard = Path(__file__).resolve().parents[1] / "apps" / "handlers" / "__init__.py" + + if not guard.exists(): + pytest.skip(f"no handler guard at {guard} — nothing to pin") + + source = guard.read_text(encoding="utf-8") + + # Control: a matcher that parses nothing reports clean. Prove it read a real + # guard before trusting its silence. + assert "_guard_branch_access" in source, ( + f"{guard} does not look like a handler guard — this pin is measuring nothing" + ) + + assert _inspect_stack_calls(source) == [], ( + "inspect.stack() is back in this branch's handler guard. It reads the " + "current working directory on Windows before any of the guard's own code " + "runs, so a process with a dead cwd cannot import this branch at all. " + "Walk frames with sys._getframe(1) and read frame.f_code.co_filename." + ) + + +def test_the_stack_matcher_can_convict(): + """Negative control for the pin above — it must be able to say yes. + + A matcher that always returns an empty list passes the pin forever while + detecting nothing. + """ + assert _inspect_stack_calls("import inspect\nx = inspect.stack()\n") == [2] + assert _inspect_stack_calls("import inspect\nx = inspect.currentframe()\n") == [] diff --git a/src/aipass/spawn/tests/conftest.py b/src/aipass/spawn/tests/conftest.py index 8769066c3..0c362b34c 100644 --- a/src/aipass/spawn/tests/conftest.py +++ b/src/aipass/spawn/tests/conftest.py @@ -153,8 +153,23 @@ def mock_infrastructure(tmp_path): @pytest.fixture def mock_logger(): - """Mock aipass.prax logger for testing log calls.""" - with patch("aipass.prax.logger") as m: + """Mock the logger that ``file_ops`` actually calls. + + MEASURED by @memory and reproduced by @seedgo (2026-08-30): the previous + spelling, ``patch("aipass.prax.logger")``, reached NOTHING. ``file_ops`` + does ``from aipass.prax.apps.modules.logger import system_logger as logger`` + at import, so the object is copied into its globals before any fixture runs + — and ``aipass/prax/__init__.py`` copies it once more one level up. Patching + at or above ``aipass.prax`` is always upstream of a copy already taken, so + every test using this fixture was talking to a mock nobody consults while + the real SystemLogger kept writing into @prax's live state directory. + + The rule underneath: THE LAST DOT MUST BE RESOLVED AT CALL TIME. So the + patch names the CONSUMING module. ``tests/test_conftest_fixtures.py`` pins + that this fixture reaches ``file_ops.logger`` by object identity, so a + future rename cannot silently return it to a mock that mocks nothing. + """ + with patch("aipass.spawn.apps.handlers.file_ops.logger") as m: yield m diff --git a/src/aipass/spawn/tests/test_conftest_fixtures.py b/src/aipass/spawn/tests/test_conftest_fixtures.py new file mode 100644 index 000000000..86d169122 --- /dev/null +++ b/src/aipass/spawn/tests/test_conftest_fixtures.py @@ -0,0 +1,63 @@ +"""Pins that spawn's own mocking fixtures reach the code they claim to mock. + +A fixture that mocks nothing is worse than no fixture: it passes, it looks like +coverage, and it lets the real object keep working — in this case writing into +@prax's live state directory from inside a test run. + +MEASURED by @memory, reproduced across the fleet by @seedgo (2026-08-30): +`patch("aipass.prax.logger")` — the spelling spawn and four other branches +used — never reached a single consumer. `file_ops` binds the logger OBJECT into +its own globals at import (`from aipass.prax.apps.modules.logger import +system_logger as logger`), and `aipass/prax/__init__.py` copies it once more one +level up, so a patch at or above `aipass.prax` is always upstream of a copy +already taken. Under all four techniques the fleet was using, the consumer's +logger was still a live SystemLogger. + +The rule: THE LAST DOT MUST BE RESOLVED AT CALL TIME. These are identity pins, +not behaviour pins, because identity is the thing that silently broke. +""" + +from unittest.mock import Mock, patch + +import aipass.spawn.apps.handlers.file_ops as file_ops + + +class TestMockLoggerReachesItsConsumer: + """`mock_logger` must be the object `file_ops` calls, not a distant cousin.""" + + def test_fixture_replaces_the_consumer_binding(self, mock_logger): + """Object identity — the only check that would have caught the old spelling.""" + assert file_ops.logger is mock_logger + + def test_the_replacement_is_actually_a_mock(self, mock_logger): + assert isinstance(file_ops.logger, Mock) + + def test_calls_through_the_consumer_are_recorded(self, mock_logger): + file_ops.logger.info("probe") + + mock_logger.info.assert_called_once_with("probe") + + def test_patching_the_prax_package_would_reach_nothing(self): + """The retired spelling, pinned as the failure it was. + + Kept as an executable record rather than a comment: if some future + refactor makes `aipass.prax.logger` the live binding again, this goes red + and says so, instead of leaving a stale warning in a docstring. + """ + before = file_ops.logger + + with patch("aipass.prax.logger") as upstream: + assert file_ops.logger is before, "the old spelling now reaches — update the fixture note" + assert file_ops.logger is not upstream + + +class TestMockJsonHandlerReachesItsConsumer: + """`mock_json_handler` already patches at the call site — pin that it stays there.""" + + def test_fixture_replaces_the_call_site(self, mock_json_handler): + assert file_ops.json_handler.log_operation is mock_json_handler + + def test_the_patched_call_is_recorded(self, mock_json_handler): + file_ops.json_handler.log_operation("probe") + + mock_json_handler.assert_called_once_with("probe") diff --git a/src/aipass/spawn/tests/test_json_durability.py b/src/aipass/spawn/tests/test_json_durability.py index 3f7888888..7df02443c 100644 --- a/src/aipass/spawn/tests/test_json_durability.py +++ b/src/aipass/spawn/tests/test_json_durability.py @@ -25,10 +25,14 @@ import ast import errno import json +import os +import sys +import tempfile import threading import time from contextlib import contextmanager from pathlib import Path +from types import SimpleNamespace import pytest @@ -39,6 +43,13 @@ RACE_SECONDS = 0.6 +# How long run() will wait for BOTH sides to prove they are live before it opens +# the timed window. On an idle machine the first read and write land inside a few +# milliseconds and this costs nothing; on a loaded one it converts "the scheduler +# was busy" from a red into a little latency. Generous on purpose — the deadline +# exists to bound a hang, not to tune a race. +RACE_WARMUP_SECONDS = 20.0 + # Marker embedded in the payload a test wants to fail. Both fault injectors below # fire only when they see it, so pytest's own I/O is never disturbed. SENTINEL = "SPAWN-DURABILITY-FAULT-a1b2c3" @@ -61,8 +72,17 @@ def __init__(self, target: Path, write_once, writers: int = 1, readers: int = 2) self.unparseable = 0 self.reads = 0 self.writes = 0 + self.missing = 0 + self.refused = 0 + self.warmed_up = False self._stop = False self._lock = threading.Lock() + # One-way flags, set by the threads the instant they first succeed, so + # run() can watch the race come alive. The per-thread counters above are + # only merged at join time, which is far too late to wait on. A bool + # that goes False -> True exactly once needs no lock under the GIL. + self._saw_read = False + self._saw_write = False def _writer(self): n = 0 @@ -79,12 +99,13 @@ def _writer(self): # the call under test decided there was nothing to write. if self.write_once(n) is True: effective += 1 + self._saw_write = True n += 1 with self._lock: self.writes += effective def _reader(self): - reads = empty = unparseable = 0 + reads = empty = unparseable = missing = refused = 0 while not self._stop: # Yield between polls — Windows share-mode semantics, not tuning. # A zero-delay spin-reader holds the target open at near-100% duty @@ -101,47 +122,130 @@ def _reader(self): try: raw = self.target.read_text(encoding="utf-8") except FileNotFoundError: + # Counted, not swallowed. "The file was not there yet" and "the + # reader never got a turn" both end with reads == 0, and the + # skip reason has to be able to tell them apart — that is the + # difference between a slow starter and a starved scheduler. + missing += 1 continue except PermissionError: # Windows refuses the open while a concurrent os.replace is in # flight. A refused open is share-mode semantics — not a torn - # document, and not counted as a read. + # document, and not counted as a read. Counted separately so a + # run starved by share-mode collisions says so out loud. + refused += 1 continue reads += 1 + self._saw_read = True if raw == "": empty += 1 continue try: json.loads(raw) except json.JSONDecodeError: + # The defect this whole file exists to catch: a half-written + # document. Recorded, and assert_clean fails on it. unparseable += 1 with self._lock: self.reads += reads self.empty += empty self.unparseable += unparseable - - def run(self, seconds: float = RACE_SECONDS): + self.missing += missing + self.refused += refused + + def run(self, seconds: float = RACE_SECONDS, warmup: float = RACE_WARMUP_SECONDS): + """Start the threads, wait until both sides are live, then race. + + The timed window used to open the instant the threads were started, so + on a loaded machine a 0.6s race could end with the reader never having + been scheduled at all — reads == 0, and assert_clean called that a + defect (@prax, whole-tree batch run 2026-08-30). Waiting for first + contact first turns that from a red into a few milliseconds of latency + on an idle box and a bounded wait on a busy one. + """ threads = [threading.Thread(target=self._writer) for _ in range(self.writers)] threads += [threading.Thread(target=self._reader) for _ in range(self.readers)] for t in threads: t.start() + + deadline = time.monotonic() + warmup + while time.monotonic() < deadline and not (self._saw_read and self._saw_write): + time.sleep(0.005) + self.warmed_up = self._saw_read and self._saw_write + time.sleep(seconds) self._stop = True for t in threads: t.join(timeout=30) return self + def final_state(self) -> str: + """One direct read of the target, after the threads have stopped. + + Returns exactly one of: ``absent`` (never created — not a tear), + ``empty`` / ``unparseable`` (torn, right now, on disk), ``unreadable`` + (the open itself was refused — share-mode or permissions, which is not + evidence of tearing and must not be convicted as such), or ``whole``. + """ + try: + raw = self.target.read_text(encoding="utf-8") + except FileNotFoundError: + return "absent" + except OSError: + return "unreadable" + if raw == "": + return "empty" + try: + json.loads(raw) + except json.JSONDecodeError: + return "unparseable" + return "whole" + def assert_clean(self): - assert self.writes > 0, "harness never rewrote the target — the race proves nothing" - assert self.reads > 0, "harness never read — the race proves nothing" + """Fail on a torn file; SKIP when the race never happened. + + Order matters and is the whole safety argument: the tear check runs + FIRST, so an observed tear fails loudly no matter how few reads or + writes the run managed. Only once the file is known clean does an + unexercised race downgrade to a skip — a test may say "I could not + measure this", but it may never say "this is broken" when that is what + it means. + + The tear check reads the target DIRECTLY here as well as through the + reader threads' samples. @prax asked for exactly this when reviewing + the skip ordering (2026-08-31), and it was a real gap rather than a + formality: the readers poll on a 1ms yield, so a file left torn by the + final write can go unsampled entirely — and a run ending with reads == 0 + would then take the skip branch while the target sits torn on disk. One + open at assert time cannot be starved by the scheduler. A missing target + is still not a tear; never-created is the case the skip exists for. + """ + final = self.final_state() bad = self.empty + self.unparseable - pct = bad / self.reads * 100 - assert bad == 0, ( - f"concurrent reader observed a torn file: {self.empty} EMPTY + " - f"{self.unparseable} UNPARSEABLE out of {self.reads} reads " - f"({pct:.2f}% unusable) across {self.writes} writes" + + sampled = f"{self.empty} EMPTY + {self.unparseable} UNPARSEABLE out of {self.reads} reads" + if self.reads: + sampled += f" ({bad / self.reads * 100:.2f}% unusable)" + + assert bad == 0 and final not in ("empty", "unparseable"), ( + f"torn file: sampled {sampled}; file on disk at the end of the run " + f"is {final.upper()} — across {self.writes} writes" ) + if self.writes == 0: + pytest.skip( + "race not exercised: harness never rewrote the target " + f"(reads={self.reads}, writes=0, final={final}) — 0 writes is a " + "result, not a defect" + ) + if self.reads == 0: + pytest.skip( + "race not exercised: harness never read " + f"(reads=0, writes={self.writes}, target-absent={self.missing}, " + f"open-refused={self.refused}, final={final}) — the file itself was " + "checked directly and is not torn; nothing else is claimed either way" + ) + @contextmanager def write_fails_midway(monkeypatch): @@ -844,11 +948,20 @@ def test_retry_waits_between_attempts(self, tmp_path, monkeypatch): and becomes decoration. It survived a mutation run on 2026-08-18. Counting the sleeps pins the wait without asserting on wall-clock time, which would be flaky on a loaded runner. + + The patch replaces atomic_write's OWN ``time`` name, not the attribute + on the shared time module. ``setattr(aw.time, "sleep", ...)`` reached + every thread in the process, so this list collected foreign sleeps too + and the test failed intermittently in a full-suite run (seen + 2026-08-30). Same rule as the logger fixture: patch the consuming + module's binding, not something upstream of it. atomic_write uses + nothing from ``time`` but ``sleep`` (one call site, line 73), so a stub + carrying only ``sleep`` is the whole surface. """ import aipass.spawn.apps.handlers.atomic_write as aw sleeps = [] - monkeypatch.setattr(aw.time, "sleep", lambda seconds: sleeps.append(seconds)) + monkeypatch.setattr(aw, "time", SimpleNamespace(sleep=sleeps.append)) monkeypatch.setattr( aw.os, "replace", @@ -1108,3 +1221,253 @@ def test_guard_flags_the_real_lock_sites_if_they_stop_being_locks(self): assert 'open(lock_path, "w"' in registry_src, ( "registry.py's flock target changed shape — re-check the guard exemption" ) + + +# ============================================================================= +# THE HARNESS ITSELF — a race that never raced must not read as a defect +# ============================================================================= + + +class TestRacerReportsWeatherHonestly: + """The guard "the race proves nothing" is right; failing for it is not. + + MEASURED by @prax 2026-08-30, running every json/log test file in the tree + as one batch from the repo root: 1380 passed, 1 failed — ours, and it failed + on its OWN guard (``reads == 0``), not on a torn read. It passes 3/3 alone. + Under a loaded machine the reader thread simply never gets scheduled inside + a 0.6s window, so the harness genuinely never read; the guard correctly + refuses to claim the race was exercised, and then reports that refusal as a + defect in the code under test. That reds a whole-repo run for weather. + + A test may say "I could not measure this". It may not say "this is broken" + when what it means is "I could not measure this" — the same species as + tonight's migrate-passports empty scan reporting an all-clear. + + So the harness now does two things: it WAITS for evidence that both sides + are live before the timed window opens (weather becomes latency, not + failure), and if even that deadline expires it SKIPS with the counters in + the reason. What it never does is let a skip mask a torn read — the tear + check runs first, so an observed tear fails loudly no matter how few reads + or writes the run managed. + """ + + def test_a_race_that_never_read_skips_rather_than_fails(self, tmp_path): + """reads == 0 is a result, not a defect.""" + racer = _Racer(tmp_path / "never_created.json", lambda n: True) + racer.run(seconds=0.05, warmup=0.05) + + assert racer.reads == 0, "fixture assumption broken — the target must never appear" + with pytest.raises(pytest.skip.Exception) as exc: + racer.assert_clean() + + assert "never read" in str(exc.value) + + def test_a_race_that_never_wrote_skips_rather_than_fails(self, tmp_path): + """writes == 0 is the same result seen from the other side.""" + target = tmp_path / "static.json" + target.write_text('{"a": 1}\n', encoding="utf-8") + + racer = _Racer(target, lambda n: False) + racer.run(seconds=0.05, warmup=0.05) + + with pytest.raises(pytest.skip.Exception) as exc: + racer.assert_clean() + + assert "never rewrote" in str(exc.value) + + def test_the_skip_reason_carries_the_counters(self, tmp_path): + """A skip nobody can diagnose is only a quieter silence.""" + racer = _Racer(tmp_path / "never_created.json", lambda n: True) + racer.run(seconds=0.05, warmup=0.05) + + with pytest.raises(pytest.skip.Exception) as exc: + racer.assert_clean() + + reason = str(exc.value) + assert "reads=0" in reason + assert "writes=" in reason + + def test_an_observed_tear_still_fails_even_with_one_read(self, tmp_path): + """ORDERING PIN — the tear check must run BEFORE the skip. + + Without this, a run unlucky enough to be short would skip past a real + torn read and call the day green. This is the pin that makes the skip + safe to add. + """ + racer = _Racer(tmp_path / "irrelevant.json", lambda n: True) + racer.reads = 1 + racer.empty = 1 + racer.writes = 0 + + with pytest.raises(AssertionError) as exc: + racer.assert_clean() + + assert "torn file" in str(exc.value) + + def test_a_clean_exercised_race_still_passes(self, tmp_path): + """The normal path is untouched.""" + from aipass.spawn.apps.handlers.atomic_write import atomic_write_text + + target = tmp_path / "live.json" + + def write_once(n): + atomic_write_text(target, json.dumps({"round": n}) + "\n") + return True + + racer = _Racer(target, write_once).run() + racer.assert_clean() + + assert racer.reads > 0 and racer.writes > 0 + + def test_warmup_waits_for_a_slow_starter(self, tmp_path): + """A target that appears late still gets a real race, not a skip. + + This is the half that turns weather into latency: the timed window does + not open until both sides have proven they are live. + """ + from aipass.spawn.apps.handlers.atomic_write import atomic_write_text + + target = tmp_path / "slow.json" + + def write_once(n): + if n < 3: + return False + atomic_write_text(target, json.dumps({"round": n}) + "\n") + return True + + racer = _Racer(target, write_once).run(seconds=0.2, warmup=20.0) + racer.assert_clean() + + assert racer.reads > 0 and racer.writes > 0 + + +@contextmanager +def _target(content): + """A _Racer over a throwaway file with a known final state, zero samples. + + ``content=None`` leaves the target absent. The counters start at zero on + purpose: these pins measure what the DIRECT read contributes, so the + sampling readers must have contributed nothing. + """ + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "final_state.json" + if content is not None: + target.write_text(content, encoding="utf-8") + racer = _Racer(target, lambda n: True) + racer.reads = 0 + racer.writes = 0 + yield racer + + +def _verdict(racer): + """Classify what assert_clean DID: FAILED / SKIPPED / PASSED. + + The pins below must be able to tell a red apart from a skip, and + ``pytest.raises(AssertionError)`` cannot: ``pytest.skip`` raises its own + exception, which sails straight past the raises block and reports the whole + test as SKIPPED — a defeat wearing a pass's clothes. That is the same + species @memory killed in their own guard tests on 2026-08-30. Catching + both here makes the verdict a value the test asserts on. + """ + try: + racer.assert_clean() + except AssertionError as exc: + return "FAILED", str(exc) + except pytest.skip.Exception as exc: + return "SKIPPED", str(exc) + return "PASSED", "" + + +class TestTheFinalStateIsCheckedDirectly: + """@prax's review question, answered by closing it rather than agreeing. + + Reviewing the skip ordering, @prax wrote: "If your tear check reads the file + directly at the end rather than only through the reader thread's samples, + that closes the last thing I would have asked about." + + It did not, and the honest answer was to build it. ``assert_clean`` judged + only what the reader THREADS happened to sample, so a target left torn on + disk was invisible whenever no sample landed on the write that tore it — + and the run then SKIPPED, with the very reasonable-sounding explanation that + nothing had been observed. The ordering was already right; the evidence set + was too small. A direct read costs one open and cannot be starved by the + scheduler, which is the exact condition under which the sampling reader + fails. + """ + + def test_a_torn_file_nobody_sampled_is_a_red_not_a_skip(self): + """reads == 0 must not launder a file that is torn right now.""" + with _target('{"half') as racer: + verdict, message = _verdict(racer) + + assert verdict == "FAILED", ( + f"a file left torn on disk was reported {verdict} — the sampling " + f"readers saw nothing, so only a direct read can catch it: {message}" + ) + assert "torn" in message.lower() + assert "UNPARSEABLE" in message + + def test_an_empty_file_nobody_sampled_is_a_red_not_a_skip(self): + with _target("") as racer: + verdict, message = _verdict(racer) + + assert verdict == "FAILED", f"an empty target was reported {verdict}: {message}" + assert "EMPTY" in message + + def test_a_missing_target_is_still_a_skip_not_a_tear(self): + """Never created is not torn — the distinction the skip exists for.""" + with _target(None) as racer: + verdict, message = _verdict(racer) + + assert verdict == "SKIPPED", f"a never-created target was reported {verdict}" + assert "final=absent" in message + + def test_a_whole_file_at_the_end_does_not_invent_a_tear(self): + """Positive control — the direct read must not manufacture reds.""" + with _target('{"round": 7}\n') as racer: + racer.reads = 5 + racer.writes = 5 + verdict, message = _verdict(racer) + + assert verdict == "PASSED", f"a whole file was reported {verdict}: {message}" + + def test_an_unreadable_target_is_not_convicted_as_torn(self): + """Share-mode / permission refusal is not evidence of tearing. + + The world here is built by chmod, and chmod does not build it + everywhere: Windows ignores POSIX mode bits (it wants an ACL), and root + reads through them on POSIX. Both were guesses in the first version of + this test — it inferred "running as root" from a PASSED verdict, which + is a cause read off a symptom, and on the Windows gate the verdict was + SKIPPED for a different reason entirely and the assertion below failed + (@devpulse, ebb8075d windows-setup). + + So the world is PROBED, not assumed: after the chmod, actually try to + read the file. If it still reads, this host cannot build the state and + the test says so with what it measured. @memory's ruling, applied — + probe the host, do not skipif what a probe can measure. + """ + with _target('{"whole": true}') as racer: + racer.target.chmod(0o000) + try: + try: + racer.target.read_text(encoding="utf-8") + except OSError: + unreadable = True + else: + unreadable = False + + verdict, message = _verdict(racer) + finally: + racer.target.chmod(0o644) + + if not unreadable: + pytest.skip( + "world not built: chmod(0o000) left the file readable on this " + f"host (platform={sys.platform}, euid={getattr(os, 'geteuid', lambda: 'n/a')()}) " + "— Windows ignores POSIX mode bits and root reads through them, " + "so the unreadable state cannot be constructed this way here" + ) + + assert verdict == "SKIPPED", f"an unreadable target was reported {verdict}: {message}" + assert "final=unreadable" in message diff --git a/src/aipass/spawn/tests/test_modules_gateway.py b/src/aipass/spawn/tests/test_modules_gateway.py index c572086ac..173a81029 100644 --- a/src/aipass/spawn/tests/test_modules_gateway.py +++ b/src/aipass/spawn/tests/test_modules_gateway.py @@ -258,9 +258,16 @@ class TestHandlerGuard: changed rather than leaving a silently-stale claim behind. """ - def test_guard_refuses_an_outside_caller_when_it_runs(self): - """Called directly with an outside caller frame, the guard says no.""" - probe = REPO_ROOT / "probe_guard_direct.py" + def test_guard_refuses_an_outside_caller_when_it_runs(self, tmp_path): + """Called directly with an outside caller frame, the guard says no. + + The probe lives in tmp_path, not REPO_ROOT. It used to be written beside + the repo root and unlinked in a finally — which still counts as two + writes into a tree the suite does not own, and @seedgo's audit-tests + write gate named them (2026-08-30). Only the CWD needs to be REPO_ROOT, + so that aipass resolves on the import path; the file does not. + """ + probe = tmp_path / "probe_guard_direct.py" probe.write_text( textwrap.dedent( """ @@ -276,15 +283,12 @@ def test_guard_refuses_an_outside_caller_when_it_runs(self): ).strip(), encoding="utf-8", ) - try: - result = subprocess.run( - [sys.executable, str(probe)], - capture_output=True, - text=True, - cwd=str(REPO_ROOT), - ) - finally: - probe.unlink() + result = subprocess.run( + [sys.executable, str(probe)], + capture_output=True, + text=True, + cwd=str(REPO_ROOT), + ) assert result.returncode == 0, result.stderr assert result.stdout.strip() == "BLOCKED" diff --git a/src/aipass/spawn/tests/test_passport_migration.py b/src/aipass/spawn/tests/test_passport_migration.py index 865318573..5afdbd686 100644 --- a/src/aipass/spawn/tests/test_passport_migration.py +++ b/src/aipass/spawn/tests/test_passport_migration.py @@ -365,17 +365,24 @@ def test_resident_project_root_is_its_own_project(self, synthetic_fleet): assert baud.relative_path == "src/baud/baud" def test_live_fleet_matches_the_measured_baseline(self): - if os.environ.get("GITHUB_ACTIONS"): - # Windows CI runs setup.sh, which mints a fresh core-only fleet - # (18, 18, 0) — a real fleet, but not the machine this baseline - # measured. The pin guards the live fleet, not any installed one. - pytest.skip("CI-installed fleet is not the measured live machine") - targets = discover_passports(repo_root()) - if not targets: - pytest.skip("No live fleet on this machine (gitignored — expected in CI)") - core = sum(1 for t in targets if t.residency == "core") - resident = sum(1 for t in targets if t.residency == "resident") - assert (len(targets), core, resident) == ( + """Pins THIS machine's fleet — and skips honestly on any world that is not it. + + The guard used to ask ``GITHUB_ACTIONS`` and "is the result empty". + Both are the wrong question: the first asks where the PROCESS is rather + than what the WORLD contains, and the second only catches the fully-bare + case. MEASURED against a core-only stand-in (@memory, 2026-08-31), and + reproduced here before the fix: 18 targets, neither skip fires, and the + (22, 18, 4) baseline reds on a machine that has done nothing wrong. + + ``_fleet_baseline_verdict`` reads the rows and says what it needs. Its + own behaviour is pinned in TestLiveBaselineGuardsOnTheWorld, including + the anti-vacuity case — a two-tier world must still be measured. + """ + shape, reason = _fleet_baseline_verdict(repo_root()) + if reason: + pytest.skip(reason) + + assert shape == ( EXPECTED_FLEET["total"], EXPECTED_FLEET["core"], EXPECTED_FLEET["resident"], @@ -903,3 +910,237 @@ def test_legacy_class_rewrite_backs_the_passport_up_first(self, tmp_path): assert backup.exists(), "sync-registry --fix rewrote a passport with no pre_v2 backup" assert backup.read_text(encoding="utf-8") == original assert _read(path)["identity"]["citizen_class"] == "specialist" + + +def _unwrapped(text: str) -> str: + """Strip ALL whitespace so an assertion cannot depend on terminal width. + + Rich hard-wraps at the console width and inserts a bare newline mid-token, + so a tmp_path long enough to cross the boundary arrives as + "..._other_rep\no". The first version of the pin below asserted on the raw + text and passed only because that run's tmp_path happened to be short — + a test that passes by luck about its own fixture is worse than no test. + """ + return "".join(text.split()) + + +class TestEmptyScanIsNotAnAllClear: + """A root with nothing findable must not read as a clean bill of health. + + MEASURED 2026-08-30 against a real sibling repository: + + drone @spawn migrate-passports --dry-run --root /home/patrick/Projects/wren + Scanned: 0 (core 0 / resident 0) + Nothing to migrate — every scanned passport is already 2.0. + + @wren's passport is schema 1.0 and was never touched. The sentence is + technically true of the empty set and false about the world: discover_passports + globs `src/aipass/*/` and `projects/*/src/*/*/`, both shaped like THIS + repository, so any external repo yields zero targets and the report calls that + success. @memory's fleet ruling depends on this command reaching six external + citizens, and a green summary is exactly what would have hidden that it cannot. + """ + + def test_zero_scanned_says_zero_scanned(self, tmp_path, capsys): + from aipass.spawn.apps.modules.migrate_passports import handle_migrate_passports + + empty_root = tmp_path / "some_other_repo" + (empty_root / "src").mkdir(parents=True) + + assert handle_migrate_passports(["--root", str(empty_root)]) == 0 + + captured = capsys.readouterr() + # The zero-scan line is a warning() — stderr by seedgo's routing rule — + # while the layout hint stays on stdout, so read both streams. + both = _unwrapped(captured.out + captured.err) + assert _unwrapped("already 2.0") not in both, "an empty scan must not report the fleet as migrated" + assert _unwrapped("No passports found") in _unwrapped(captured.err), "the zero-scan notice belongs on stderr" + + def test_zero_scanned_names_the_root_it_searched(self, tmp_path, capsys): + from aipass.spawn.apps.modules.migrate_passports import handle_migrate_passports + + empty_root = tmp_path / "some_other_repo" + empty_root.mkdir() + + handle_migrate_passports(["--root", str(empty_root)]) + + captured = capsys.readouterr() + assert _unwrapped(str(empty_root)) in _unwrapped(captured.out + captured.err) + + def test_a_populated_root_still_reports_the_all_clear(self, synthetic_fleet, capsys): + """The fix must not silence the real all-clear — migrate, then re-run.""" + from aipass.spawn.apps.modules.migrate_passports import handle_migrate_passports + + handle_migrate_passports(["--root", str(synthetic_fleet), "--confirm"]) + capsys.readouterr() + + handle_migrate_passports(["--root", str(synthetic_fleet)]) + + captured = capsys.readouterr() + assert _unwrapped("already 2.0") in _unwrapped(captured.out) + assert _unwrapped("No passports found") not in _unwrapped(captured.out + captured.err) + + +class TestRetiredAndHiddenDirectoriesAreNeverScanned: + """The docstring's promise, made true by rule instead of by luck. + + discover_passports has always claimed "backups, archives and the template + never match either glob". MEASURED 2026-08-31, that was true by accident of + layout, not by construction — pathlib's ``*`` matches dotted names, so a + passport at ``src/aipass/.archive/.trinity/passport.json`` IS returned by + CORE_GLOB. It has simply never existed here. + + The hazard is not hypothetical elsewhere: Vera-Studio holds five archived + passports under ``src/.archive/`` (architect, creative, growth, quality, + strategy), and @memory measured the same five from the other direction — a + passport walk finds 9 branches there where the registry knows 4. If this + tool ever learns to scan a declared external root, an unruled glob offers to + write to five directories somebody deliberately retired. + + The rule: a citizen directory never begins with a dot. Retiring is a + registry act, and a passport walk cannot tell a retired citizen from a live + one — so the walk must at minimum refuse the hidden directories that + retirement puts things in. + """ + + def _plant(self, root, relative): + passport = root / relative / ".trinity" / "passport.json" + passport.parent.mkdir(parents=True) + passport.write_text(json.dumps({"document_metadata": {"schema_version": "1.0.0"}}), encoding="utf-8") + return passport + + def test_an_archived_core_passport_is_not_discovered(self, tmp_path): + self._plant(tmp_path, Path("src/aipass/.archive")) + live = self._plant(tmp_path, Path("src/aipass/realbranch")) + + found = [t.path for t in discover_passports(tmp_path)] + + assert found == [live], "an archived passport was swept into the migration set" + + def test_a_backup_directory_is_not_discovered(self, tmp_path): + self._plant(tmp_path, Path("src/aipass/.backup")) + live = self._plant(tmp_path, Path("src/aipass/realbranch")) + + assert [t.path for t in discover_passports(tmp_path)] == [live] + + def test_an_archived_resident_passport_is_not_discovered(self, tmp_path): + self._plant(tmp_path, Path("projects/demo/src/demo/.archive")) + live = self._plant(tmp_path, Path("projects/demo/src/demo/realbranch")) + + found = [t.path for t in discover_passports(tmp_path)] + + assert found == [live] + + def test_a_hidden_project_directory_is_not_discovered(self, tmp_path): + """The dot can sit at any level of the resident path, not just the last.""" + self._plant(tmp_path, Path("projects/.archive/src/demo/oldbranch")) + live = self._plant(tmp_path, Path("projects/demo/src/demo/realbranch")) + + assert [t.path for t in discover_passports(tmp_path)] == [live] + + def test_a_normal_fleet_is_untouched_by_the_rule(self, synthetic_fleet): + """The rule must not cost a single real citizen.""" + assert len(discover_passports(synthetic_fleet)) > 0 + + +# ============================================================================= +# THE LIVE BASELINE — guard on the WORLD, never on where you happen to be +# ============================================================================= + + +def _fleet_baseline_verdict(root): + """Return ``(shape, skip_reason)`` for the live-baseline pin. + + ``shape`` is ``(total, core, resident)``. ``skip_reason`` is None when the + world under ``root`` genuinely IS the machine the baseline measured, and a + sentence explaining why not otherwise. + + Extracted from the test so the guard itself can be pinned against synthetic + worlds. A guard that only ever runs against this one machine is a guard + nobody can prove, which is how the previous one shipped a hole. + """ + targets = discover_passports(root) + core = sum(1 for t in targets if t.residency == "core") + resident = sum(1 for t in targets if t.residency == "resident") + shape = (len(targets), core, resident) + + if not targets: + return shape, ( + "no fleet under this root — .trinity/ and AIPASS_REGISTRY.json are both " + "gitignored, so a bare checkout carries none; nothing is claimed either way" + ) + if resident == 0: + return shape, ( + f"world is core-only ({core} core, 0 resident) — a real fleet, but not the " + "machine this baseline measured; setup.sh mints exactly this shape" + ) + return shape, None + + +class TestLiveBaselineGuardsOnTheWorld: + """EXISTENCE IS NOT SUFFICIENCY — the hole that would have redded the train. + + The previous guard skipped on ``GITHUB_ACTIONS`` and on a completely empty + result. Both are the wrong question. The first asks WHERE THE PROCESS IS + rather than what the world contains, so it protects one CI provider and + nobody else; the second only catches the fully-bare case. @memory ran this + pin against a CI-shaped stand-in and got (18, 18, 0) — a real fleet with no + residents, non-empty, env var unset, so both guards passed and the hardcoded + (22, 18, 4) assertion failed on a machine that had done nothing wrong. + + A baseline that measures the live machine must READ THE ROWS and say what it + needs. These pins feed the guard synthetic worlds so the rule is proven + rather than asserted — including the one that matters most: on a world that + DOES carry both tiers, the guard must still engage, or the fix would have + silently switched the pin off everywhere. + """ + + def _plant(self, root, relative): + passport = root / relative / ".trinity" / "passport.json" + passport.parent.mkdir(parents=True) + passport.write_text(json.dumps({"document_metadata": {"schema_version": "1.0.0"}}), encoding="utf-8") + + def test_a_bare_checkout_skips_and_says_why(self, tmp_path): + shape, reason = _fleet_baseline_verdict(tmp_path) + + assert shape == (0, 0, 0) + assert reason is not None + assert "gitignored" in reason + + def test_a_core_only_world_skips_instead_of_failing(self, tmp_path): + """The exact shape @memory measured: 18 core, 0 resident, non-empty.""" + for i in range(18): + self._plant(tmp_path, Path(f"src/aipass/branch{i:02d}")) + + shape, reason = _fleet_baseline_verdict(tmp_path) + + assert shape == (18, 18, 0) + assert reason is not None, "a core-only fleet must not be measured against this machine's baseline" + assert "core-only" in reason + + def test_the_skip_reason_reports_the_counts_it_saw(self, tmp_path): + for i in range(3): + self._plant(tmp_path, Path(f"src/aipass/branch{i}")) + + _, reason = _fleet_baseline_verdict(tmp_path) + + assert reason is not None + assert "3 core" in reason + assert "0 resident" in reason + + def test_a_world_with_both_tiers_still_engages_the_guard(self, tmp_path): + """ANTI-VACUITY PIN — the fix must not switch the baseline off everywhere.""" + self._plant(tmp_path, Path("src/aipass/spawn")) + self._plant(tmp_path, Path("projects/baud/src/baud/baud")) + + shape, reason = _fleet_baseline_verdict(tmp_path) + + assert shape == (2, 1, 1) + assert reason is None, "a two-tier world IS measurable — the guard must not skip it" + + def test_the_live_machine_is_measured_or_says_why(self): + """Whatever this machine is, the verdict must be self-describing.""" + shape, reason = _fleet_baseline_verdict(repo_root()) + + assert len(shape) == 3 + assert reason is None or isinstance(reason, str) diff --git a/src/aipass/spawn/tests/test_registry_credential.py b/src/aipass/spawn/tests/test_registry_credential.py index d5304d303..4f957a7ea 100644 --- a/src/aipass/spawn/tests/test_registry_credential.py +++ b/src/aipass/spawn/tests/test_registry_credential.py @@ -15,14 +15,23 @@ which in practice is AIPass's own id, so a brand-new project's agent displays a number belonging to a project it has never been part of. -The two default-schema paths in load_registry are deliberately NOT symmetric, -and these tests pin that asymmetry: +The mint is deliberately ASYMMETRIC, and these tests pin that asymmetry: - registry file ABSENT -> a genuinely new project -> mint a credential. - registry file PRESENT but unreadable -> the project ALREADY HAS a credential we simply cannot read. Minting a fresh one here would re-credential a live project and orphan every existing passport, so this path must NOT invent one. + +MOVED 2026-08-31: that asymmetry used to live inside ``load_registry``, which +meant a READ minted an identity for any path that did not exist — and the shared +resolver used to hand out ``Path.cwd() / AIPASS_REGISTRY.json`` for an absence, +so the two composed into a project credential appearing in whatever directory +the caller stood in (@memory). The asymmetry is unchanged and still pinned; it +now lives in ``resolve_project_credential``, which the create sites call by +name. The three tests that asserted the mint against ``load_registry`` moved to +``TestResolveProjectCredentialIsTheExplicitMint`` rather than being deleted — +the behaviour they guard is the same behaviour, one layer over. """ import json @@ -38,21 +47,6 @@ # ============================================================================= -def test_missing_registry_is_born_with_a_credential(tmp_path): - """A registry that does not exist yet gets a real minted id.""" - result = load_registry(tmp_path / "NEW_REGISTRY.json") - - assert uuid.UUID(result["metadata"]["id"]) # parses => real UUID, not "" or None - - -def test_minted_credential_is_unique_per_registry(tmp_path): - """Two new projects must not share a credential — it is their lock.""" - first = load_registry(tmp_path / "ONE_REGISTRY.json")["metadata"]["id"] - second = load_registry(tmp_path / "TWO_REGISTRY.json")["metadata"]["id"] - - assert first != second - - def test_minted_default_keeps_the_rest_of_the_schema(tmp_path): """Adding the credential must not disturb the fields callers already read.""" result = load_registry(tmp_path / "NEW_REGISTRY.json") @@ -63,13 +57,6 @@ def test_minted_default_keeps_the_rest_of_the_schema(tmp_path): assert "last_updated" in result["metadata"] -def test_new_project_credential_is_not_aipass_own_id(tmp_path): - """The regression this exists to prevent: inheriting AIPass's credential.""" - result = load_registry(tmp_path / "NEW_REGISTRY.json") - - assert result["metadata"]["id"] != "7087bb93-570f-4b9a-b035-4fd7f570200e" - - # ============================================================================= # EXISTING REGISTRY — never re-credential what we merely failed to read # ============================================================================= @@ -149,3 +136,313 @@ def test_existing_registry_credential_survives_a_new_citizen(tmp_path): add_to_registry(reg, "WIDGET", str(branch), "p", "@widget", credential=str(uuid.uuid4())) assert json.loads(reg.read_text(encoding="utf-8"))["metadata"]["id"] == "the-real-lock" + + +# ============================================================================= +# THE LOAD-SIDE MINT — a read must not create an identity +# ============================================================================= + + +class TestLoadDoesNotMint: + """Loading is a READ. Minting a project credential is a DECISION. + + MEASURED by @memory 2026-08-31, statically proven against live code: the + shared resolver ``registry_discovery.find_registry`` returns + ``Path.cwd() / AIPASS_REGISTRY.json`` when nothing exists — a PATH for an + ABSENCE — and ``load_registry`` then answered that path with a well-formed + document carrying a freshly minted ``metadata.id``. Compose the two and + ``save_registry`` writes a registry with a brand-new project credential into + whatever directory the caller happened to be standing in. Nobody calls it + that way today: a loaded gun, not a fired one. + + The 2026-08-24 fix this replaces was right about the DEFECT — a new project + whose first citizen inherits AIPass's own id — and wrong about WHERE to fix + it. The create path knows it is creating; the load path does not know + anything. So the mint moved to ``resolve_project_credential``, which the two + real create sites call by name, and ``load_registry`` went back to being a + read. The asymmetry the old file pinned survives intact, one layer over: + absent means mint (when a creator asks), unreadable never does. + + Fleet ruling this implements: absence is a signal said out loud, never + silently papered into a valid-looking value. + """ + + def test_missing_registry_yields_no_credential(self, tmp_path): + result = load_registry(tmp_path / "NEW_REGISTRY.json") + + assert result["metadata"].get("id") in (None, ""), ( + "load_registry minted a project credential for a file that does not exist" + ) + + def test_missing_registry_still_returns_a_usable_empty_document(self, tmp_path): + """Refusing to mint must not break the sixteen callers that just read branches.""" + result = load_registry(tmp_path / "NEW_REGISTRY.json") + + assert result["branches"] == [] + assert result["metadata"]["version"] == "1.0.0" + assert result["metadata"]["total_branches"] == 0 + + def test_the_absence_is_said_out_loud(self, tmp_path, caplog): + """A silent empty document is how the composition stayed invisible.""" + import logging + + with caplog.at_level(logging.INFO): + load_registry(tmp_path / "ABSENT_REGISTRY.json") + + assert any("ABSENT_REGISTRY.json" in record.getMessage() for record in caplog.records), ( + "load_registry returned an empty document for a missing file without saying so" + ) + + def test_the_two_line_composition_writes_no_credential(self, tmp_path): + """@memory's exact reproduction — load a path that does not exist, save it.""" + from aipass.spawn.apps.handlers.registry import save_registry + + path = tmp_path / "AIPASS_REGISTRY.json" + save_registry(path, load_registry(path)) + + written = json.loads(path.read_text(encoding="utf-8")) + assert written["metadata"].get("id") in (None, ""), ( + "a registry with a brand-new project credential appeared out of nothing" + ) + + +class TestResolveProjectCredentialIsTheExplicitMint: + """The decision, made where the caller knows it is creating a project.""" + + def test_absent_registry_mints_a_real_uuid(self, tmp_path): + from aipass.spawn.apps.handlers.registry import resolve_project_credential + + assert uuid.UUID(resolve_project_credential(tmp_path / "NEW_REGISTRY.json")) + + def test_each_new_project_gets_its_own(self, tmp_path): + from aipass.spawn.apps.handlers.registry import resolve_project_credential + + first = resolve_project_credential(tmp_path / "ONE_REGISTRY.json") + second = resolve_project_credential(tmp_path / "TWO_REGISTRY.json") + + assert first != second + + def test_it_is_not_aipass_own_id(self, tmp_path): + """The regression the 08-24 fix exists to prevent, still pinned.""" + from aipass.spawn.apps.handlers.registry import resolve_project_credential + + assert resolve_project_credential(tmp_path / "NEW_REGISTRY.json") != "7087bb93-570f-4b9a-b035-4fd7f570200e" + + def test_existing_registry_hands_back_its_own_id(self, tmp_path): + from aipass.spawn.apps.handlers.registry import resolve_project_credential + + path = tmp_path / "REAL_REGISTRY.json" + path.write_text( + json.dumps({"metadata": {"id": "keep-me", "version": "1.0.0", "total_branches": 0}, "branches": []}), + encoding="utf-8", + ) + + assert resolve_project_credential(path) == "keep-me" + + def test_unreadable_registry_mints_nothing(self, tmp_path): + """Unreadable is not absent — re-credentialling orphans every passport.""" + from aipass.spawn.apps.handlers.registry import resolve_project_credential + + corrupt = tmp_path / "CORRUPT_REGISTRY.json" + corrupt.write_text("{not valid json", encoding="utf-8") + + assert resolve_project_credential(corrupt) == "" + + def test_resolving_writes_nothing_to_disk(self, tmp_path): + """Minting a value is not creating a file — the create step does that.""" + from aipass.spawn.apps.handlers.registry import resolve_project_credential + + path = tmp_path / "NEW_REGISTRY.json" + resolve_project_credential(path) + + assert not path.exists() + assert list(tmp_path.iterdir()) == [] + + +# ============================================================================= +# THE OTHER HALF LANDED FIRST — find_registry now answers absence with None +# ============================================================================= + + +class TestAbsentRegistryResolverIsHandled: + """@aipass's half of tonight's ruling is already in the tree, and it reaches here. + + FOUND, not authored by me: ``aipass/shared/registry_discovery.find_registry`` + now returns ``None`` on absence instead of ``Path.cwd() / AIPASS_REGISTRY.json``. + Its docstring names the reason and names this branch — "a path that need not + exist is a lie with a type signature, and @spawn's load_registry mints a + fresh metadata.id for exactly such a path". Correct, and it is the resolver + half of the same fix as TestLoadDoesNotMint above. + + The consequence is mine: spawn had ~18 call sites written against a resolver + that always returned a Path, and four of them dereference the result with no + guard. Those were not type-checker noise — ``None.exists()`` is an + AttributeError, and in ``is_protected`` it would escape a try that only + catches OSError/ValueError/KeyError, turning "no registry here" into a crash + inside a SAFETY check. + + Absence is answered by name, in each function's own vocabulary: not + protected, refused, no owner. + """ + + def test_is_protected_answers_not_protected_when_there_is_no_registry(self, tmp_path, monkeypatch): + from aipass.spawn.apps.handlers import registry as registry_module + + monkeypatch.setattr(registry_module, "find_registry", lambda *a, **k: None) + + protected, reason = registry_module.is_protected("somebranch") + + assert protected is False + assert reason + + def test_the_protected_floor_still_wins_without_a_registry(self, tmp_path, monkeypatch): + """A missing registry must never unprotect infrastructure.""" + from aipass.spawn.apps.handlers import registry as registry_module + + monkeypatch.setattr(registry_module, "find_registry", lambda *a, **k: None) + + protected, reason = registry_module.is_protected("spawn") + + assert protected is True + assert "infrastructure" in reason + + def test_ensure_admin_refuses_rather_than_crashing(self, monkeypatch): + from aipass.spawn.apps.handlers import registry as registry_module + + monkeypatch.setattr(registry_module, "find_registry", lambda *a, **k: None) + + status, reason = registry_module.ensure_admin() + + assert status == "refused" + assert "registry" in reason.lower() + + def test_get_owner_returns_none_when_there_is_no_registry(self, monkeypatch): + from aipass.spawn.apps.handlers import registry as registry_module + + monkeypatch.setattr(registry_module, "find_registry", lambda *a, **k: None) + + assert registry_module.get_owner() is None + + +class TestCreatePathStillStampsACredential: + """END-TO-END regression guard for the 2026-08-24 defect, after the move. + + The mint left ``load_registry``; it must not have left the product. A + citizen created into a brand-new project must still be born with a real + project credential in its passport, and the registry that lands must carry + the SAME one — the whole reason the credential is resolved before the + passport is written rather than at registration time. + """ + + def test_a_new_project_citizen_gets_a_real_credential_in_both_files(self, tmp_path): + from aipass.spawn.apps.modules.core import _spawn_agent + + project = tmp_path / "brandnew" + project.mkdir() + (project / "pyproject.toml").write_text("[project]\nname='brandnew'\n", encoding="utf-8") + + result = _spawn_agent(str(project / "src" / "brandnew" / "widget")) + assert result.get("success"), result + + passport = json.loads( + (project / "src" / "brandnew" / "widget" / ".trinity" / "passport.json").read_text(encoding="utf-8") + ) + credential = passport["citizenship"]["registry_id"] + + assert uuid.UUID(credential), "the new project's citizen was born without a credential" + assert credential != "7087bb93-570f-4b9a-b035-4fd7f570200e", "inherited AIPass's own id" + + registry = json.loads((project / "AIPASS_REGISTRY.json").read_text(encoding="utf-8")) + assert registry["metadata"]["id"] == credential, ( + "the passport claims one credential and the registry carries another" + ) + + +class TestNoRegistryAnywhereIsHandledOnTheCreatePath: + """The other consumers of the now-nullable resolver, pinned by behaviour.""" + + def test_relative_path_falls_back_to_the_bare_name(self, tmp_path, monkeypatch): + from aipass.spawn.apps.handlers import placeholders + + monkeypatch.setattr(placeholders, "find_registry", lambda *a, **k: None) + target = tmp_path / "nowhere" / "widget" + target.mkdir(parents=True) + + assert placeholders.resolve_relative_path(target) == "widget" + + def test_replacements_leave_registry_id_empty_rather_than_inventing_one(self, tmp_path, monkeypatch): + from aipass.spawn.apps.handlers import placeholders + + monkeypatch.setattr(placeholders, "find_registry", lambda *a, **k: None) + target = tmp_path / "nowhere" / "widget" + target.mkdir(parents=True) + + replacements = placeholders.build_replacements_dict(str(target), "widget") + + assert replacements["REGISTRY_ID"] == "", "a credential nobody issued was substituted" + + +class TestEveryResolverConsumerRefusesByName: + """Sweep pin: no spawn entry point may crash on an absent registry. + + @aipass's resolver change reached eleven more sites than the four the first + pass caught — none exercised by a test that runs without a registry, all of + them one ``None.parent`` away from a traceback. Type errors found them; these + pins keep them found, because "the type checker was green that day" is not a + thing a future reader can verify. + + Each refuses in its own vocabulary rather than raising: a delete that cannot + consult the protection layer must not run, a sync has nothing to synchronise, + an adoption has no project to adopt into. + """ + + def _no_registry(self, monkeypatch, module): + monkeypatch.setattr(module, "find_registry", lambda *a, **k: None) + + def test_sync_registry_reports_the_absence(self, monkeypatch): + from aipass.spawn.apps.handlers import sync_registry_ops + + self._no_registry(monkeypatch, sync_registry_ops) + result = sync_registry_ops.sync_registry() + + assert "no registry" in result["error"].lower() + assert result["stale"] == [] and result["unregistered"] == [] + + def test_check_owner_identity_flags_the_absence(self, monkeypatch): + from aipass.spawn.apps.handlers import sync_registry_ops + + self._no_registry(monkeypatch, sync_registry_ops) + result = sync_registry_ops.check_owner_identity() + + assert result["clean"] is False + assert any(issue["flag"] == "no_registry" for issue in result["issues"]) + + def test_delete_refuses_rather_than_deleting_unchecked(self, monkeypatch): + """The most important one: no protection layer means no delete.""" + from aipass.spawn.apps.handlers import delete_ops + + self._no_registry(monkeypatch, delete_ops) + result = delete_ops.delete_branch("somebranch") + + assert result["success"] is False + assert "no *_REGISTRY.json" in result["error"] + + def test_update_all_returns_nothing_to_update(self, monkeypatch): + from aipass.spawn.apps.handlers import update_ops + + self._no_registry(monkeypatch, update_ops) + + assert update_ops.update_all() == [] + + def test_adoption_refuses_with_no_project_to_adopt_into(self, tmp_path, monkeypatch): + from aipass.spawn.apps.handlers import adoption_ops + + self._no_registry(monkeypatch, adoption_ops) + target = tmp_path / "orphan" + (target / ".trinity").mkdir(parents=True) + (target / ".trinity" / "passport.json").write_text("{}", encoding="utf-8") + + result = adoption_ops.adopt_existing(target, "", "", None) + + assert result["success"] is False + assert "no *_REGISTRY.json" in result["error"] diff --git a/src/aipass/spawn/tests/test_repair.py b/src/aipass/spawn/tests/test_repair.py index 522d7ad57..9768571bd 100644 --- a/src/aipass/spawn/tests/test_repair.py +++ b/src/aipass/spawn/tests/test_repair.py @@ -770,3 +770,122 @@ def test_retired_class_named_template_dirs_are_archived_not_live(self): pytest.skip("templates/.archive/ absent — clean checkout; the archive half is a live-machine fact") for retired in ("aipass_framework", "project_agent"): assert (archive / retired).is_dir(), f"templates/{retired}/ was deleted, not archived" + + +# --------------------------------------------------------------------------- +# Case-insensitive volumes: the glob is not a filter everywhere it runs +# --------------------------------------------------------------------------- + + +def _case_insensitive_listing(monkeypatch): + """Make pathlib's glob behave the way a Windows volume does. + + The defect is not in the reader — it is in what the FILESYSTEM hands the + reader back, so this supplies that listing rather than patching the code + under test. Matching the pattern with ``re.IGNORECASE`` is exactly what a + case-insensitive volume does, and it means these pins run RED on the Linux + dev box instead of only on the Windows gate (@drone's construction, adopted + here on @devpulse's relay, 2026-08-31). + """ + import fnmatch + import pathlib + import re + + real_glob = pathlib.Path.glob + + def insensitive_glob(self, pattern, *args, **kwargs): + if "/" in pattern or "**" in pattern: + return real_glob(self, pattern, *args, **kwargs) + rx = re.compile(fnmatch.translate(pattern), re.IGNORECASE) + return iter(sorted(p for p in self.iterdir() if rx.match(p.name))) + + monkeypatch.setattr(pathlib.Path, "glob", insensitive_glob) + + +class TestRegistryLookupIsCaseSensitive: + """A repair lane must never read a template counter as the registry. + + ``.template_registry.json`` (every branch has one) and the ten ``flow_json`` + plan counters are lowercase, and ``pathlib``'s ``*`` matches dotted names + unlike a shell glob. On a case-insensitive volume the glob returns them, and + a dotted name sorts FIRST — so the unfiltered lookup handed + ``.template_registry.json`` to the code that repairs registries. + + Both pins below are red without the suffix filter and green with it. + """ + + def test_a_lowercase_lookalike_is_not_served_as_the_registry(self, tmp_path, monkeypatch): + from aipass.spawn.apps.handlers.repair_ops import _registry_in + + real = tmp_path / "AIPASS_REGISTRY.json" + real.write_text(json.dumps({"branches": []}), encoding="utf-8") + decoy = tmp_path / ".template_registry.json" + decoy.write_text(json.dumps({"files": {}}), encoding="utf-8") + + _case_insensitive_listing(monkeypatch) + + # The decoy sorts first, so an unfiltered first-match returns it. + assert sorted(p.name for p in tmp_path.glob("*_REGISTRY.json"))[0] == decoy.name + + assert _registry_in(tmp_path) == real + + def test_repair_project_reports_the_real_registry(self, tmp_path, monkeypatch): + """End-to-end through the call site, not just the helper.""" + from aipass.spawn.apps.handlers.repair_ops import repair_project + + project = tmp_path / "someproj" + project.mkdir() + (project / "AIPASS_REGISTRY.json").write_text(json.dumps({"branches": []}), encoding="utf-8") + (project / ".template_registry.json").write_text(json.dumps({"files": {}}), encoding="utf-8") + (project / "flow_plans_registry.json").write_text("{}", encoding="utf-8") + + _case_insensitive_listing(monkeypatch) + + result = repair_project(project, dry_run=True) + + assert result["success"] is True + assert result["registry"] == "AIPASS_REGISTRY.json" + + def test_an_external_lowercase_stem_is_still_a_registry(self, tmp_path, monkeypatch): + """Suffix only, never the stem — external projects name their own.""" + from aipass.spawn.apps.handlers.repair_ops import _registry_in + + theirs = tmp_path / "vera_studio_REGISTRY.json" + theirs.write_text(json.dumps({"branches": []}), encoding="utf-8") + + _case_insensitive_listing(monkeypatch) + + assert _registry_in(tmp_path) == theirs + + def test_absence_is_reported_as_absence(self, tmp_path, monkeypatch): + from aipass.spawn.apps.handlers.repair_ops import _registry_in + + (tmp_path / ".template_registry.json").write_text("{}", encoding="utf-8") + + _case_insensitive_listing(monkeypatch) + + assert _registry_in(tmp_path) is None + + def test_both_call_sites_go_through_the_one_lookup(self): + """The extraction is the fix — a second inline glob would undo it.""" + import ast + import inspect + + from aipass.spawn.apps.handlers import repair_ops + + tree = ast.parse(inspect.getsource(repair_ops)) + inline = [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in {"glob", "rglob"} + and any( + isinstance(a, ast.Constant) and isinstance(a.value, str) and a.value.upper().endswith("_REGISTRY.JSON") + for a in node.args + ) + ] + assert inline == [], ( + "an unfiltered registry glob is back at line(s) " + f"{inline} — route it through _registry_in, which case-checks the name" + ) diff --git a/src/aipass/spawn/tests/test_template_import_guard.py b/src/aipass/spawn/tests/test_template_import_guard.py new file mode 100644 index 000000000..43c9bfd32 --- /dev/null +++ b/src/aipass/spawn/tests/test_template_import_guard.py @@ -0,0 +1,3494 @@ +"""What the newborn's handler guard must survive on its very first import. + +Every citizen is born carrying `apps/handlers/__init__.py` from the template, +and that file runs `_guard_branch_access()` at import time — so a defect in it +is not a defect in one branch, it is a defect in every branch the factory has +ever shipped and every one it will ship. + +MEASURED 2026-08-30 (@drone's dead-cwd pin, reported by @devpulse): the guard +resolved frame filenames BEFORE skipping pseudo-files like ``, and +`Path(...).resolve()` on a relative or pseudo filename calls `os.getcwd()`. Any +process whose working directory had been deleted therefore died with +FileNotFoundError while importing ANY branch. All 18 live copies were fixed in +32db831c; these pins guard the TEMPLATE, so the next spawned branch is born +with the guarded form instead of re-inheriting the defect. + +The tests render the template into a throwaway package and import it in a +subprocess, because that is the only way to exercise a file whose whole +behaviour happens at import time. The defect pins reproduce it in two worlds — +an injected cwd failure that runs on every OS, and a genuinely deleted directory +that runs wherever the OS allows the recipe — and the fence pins exist so the +fix cannot be mistaken for a weakened guard: it must still refuse an outside +caller and still admit an inside one. + +ON THE CPYTHON LINE NUMBERS IN THIS FILE (@skills' round-9 correction): every +`pathlib.py:NNN` and `ntpath.py:NNN` below is a DATED COURTESY to the reader, +not the claim. They were read on 3.12.3 here, and on the 3.10/3.11/3.13 sources +fetched for the round that needed them, and they move between patch releases — +@skills and I cited two different numbers for one getcwd read within a day. What +is falsifiable, and what every comment states in words beside the number, is the +MECHANISM and the ORDERING: which call happens above which check, and what is +captured when. A pin whose reasoning rests on a line number fails open on the +next bugfix release, silently, which is the line-scoped-waiver species one +context over. +""" + +import ast +import importlib +import ntpath +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest +from _pytest.outcomes import Failed, Skipped + +from aipass.spawn.apps.handlers.class_registry import get_available_classes, get_template_dir + + +TEMPLATE_CLASSES = sorted(get_available_classes()) + +GUARD_RELATIVE_PATH = Path("apps") / "handlers" / "__init__.py" + + +def _template_guard(class_name: str) -> Path: + return get_template_dir(class_name) / GUARD_RELATIVE_PATH + + +def _render(source: str) -> str: + """Fill the two branch placeholders the way a real mint does.""" + return source.replace("{{BRANCHNAME}}", "NEWBORN").replace("{{BRANCH}}", "newborn") + + +def _plant_newborn(root: Path, class_name: str) -> Path: + """Write the rendered guard at the path a real newborn would carry it. + + Returns the directory to put on sys.path. + """ + package = root / "aipass" / "newborn" / "apps" / "handlers" + package.mkdir(parents=True) + + for parent in (root / "aipass", root / "aipass" / "newborn", root / "aipass" / "newborn" / "apps"): + (parent / "__init__.py").write_text("", encoding="utf-8") + + (package / "__init__.py").write_text( + _render(_template_guard(class_name).read_text(encoding="utf-8")), + encoding="utf-8", + ) + return root + + +def _run(script: str, cwd: Path) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", textwrap.dedent(script).strip()], + capture_output=True, + text=True, + cwd=str(cwd), + ) + + +# ============================================================================= +# The two worlds, and why both exist +# ============================================================================= + +# The condition these pins reproduce is "the cwd read RAISES", not "a directory +# was deleted" — deletion is one way to arrive there, and on Windows it is not +# an available one: Windows locks a process's current directory, so the recipe +# dies at rmdir with WinError 32 before the test reaches its claim (round-2 +# Windows gate, relayed by @devpulse 2026-08-31). +# +# MY PICK, stated so it can be quoted: BOTH, scoped by what each can honestly +# measure — @memory's injection as the portable pin that runs on every OS, and +# @drone's real-world recipe kept and skipped on win32, because Windows makes +# the RECIPE unavailable, not the STATE (a disconnected share or an ejected +# volume hands a live Windows process a dead cwd). +# +# @devpulse asked whether mine are the quiet species — a relative path resolving +# to the WRONG place rather than crashing — because the injection would then +# need to fake a different world. I MEASURED IT RATHER THAN ANSWERING: I rebuilt +# the pre-32db831c form (resolve first, skip second) and ran it with a live cwd +# from outside the newborn tree. Both forms imported cleanly. The old form +# still skipped `` — it just paid for the resolve first — so the ONLY +# behavioural difference between defective and fixed is whether that resolve +# raises. There is no quiet species here, and that is precisely what licenses +# the injection: it reproduces the whole of the discriminator, not part of it. +# +# The injection carries a POSITIVE CONTROL for the same reason @memory's own +# skip-reads-green mutant taught the fleet: a faked world that fails to fake +# anything makes every pin pass. The child proves the call site is broken before +# it imports, and a test whose world did not take SKIPS with that reason instead +# of reporting a green it did not earn. + +WINDOWS_RECIPE_SKIP = ( + "Windows locks a process's current working directory, so this recipe dies at " + "rmtree with WinError 32 before reaching its claim. Windows makes the RECIPE " + "unavailable, not the STATE — the portable injection pin above covers the " + "state on every OS, and a disconnected share is how a live Windows process " + "reaches it for real." +) + +# The control probe is the SAME text in both worlds, so neither world can be +# graded by a friendlier instrument than the other. It runs the exact call the +# defective guard made — Path("").resolve() — and reports whether that +# call is currently broken. +_CONTROL_PROBE = """ + try: + _ProbePath("").resolve() + except OSError: + print("CONTROL_LIVE") + else: + print("CONTROL_DEAD") +""" + +_INJECT_DEAD_CWD = ( + """ + import errno, os + from pathlib import Path as _ProbePath + + def _no_cwd(*args, **kwargs): + raise FileNotFoundError(errno.ENOENT, "No such file or directory") + + os.getcwd = _no_cwd +""" + + _CONTROL_PROBE +) + +_DELETE_CWD = ( + """ + import os, shutil, tempfile + from pathlib import Path as _ProbePath + + doomed = tempfile.mkdtemp() + os.chdir(doomed) + shutil.rmtree(doomed) +""" + + _CONTROL_PROBE +) + +# Rebinding os.path.realpath is NOT enough on every interpreter, and the same +# four lines are needed by the direct-call probe further down. Shared text, so +# neither site can be armed by a friendlier instrument than the other. +# +# Python <=3.10: pathlib delegates Path.resolve through an accessor that did +# ``realpath = staticmethod(os.path.realpath)`` at pathlib's FIRST IMPORT +# (3.10 pathlib.py:358). The capture means a later rebinding of +# os.path.realpath is a name nothing reads again, so the world is silently +# INERT for anything reaching realpath through pathlib. 3.11+ looks realpath up +# on the flavour module at call time (3.12: ``self._flavour.realpath(...)``) and +# needs none of this. hasattr-guarded, so it is a no-op where the accessor does +# not exist rather than an interpreter check that rots. +# +# staticmethod on the CLASS, and NOTHING on the instance. +# +# The staticmethod matters: a plain function set on the class binds on instance +# access and eats the path into self, which would keep a raise-shaped pin green +# for a reason that has nothing to do with what it claims. +# +# The instance half is GONE, and the reversal is worth the paragraph because I +# recommended it to @memory one session ago. @drone measured that an instance +# attribute SHADOWS the class staticmethod, so carrying both makes the class +# half unfalsifiable — belt-and-braces that disarms the instrument. @memory +# checked the sufficiency claim in CPython rather than accepting either of us, +# and I read the same lines to confirm before changing my own tree: 3.10 +# pathlib.py:361 builds ONE shared `_normal_accessor` carrying no instance +# attributes, and :954 hands that same object to Path — so the lookup falls +# through to the class every time. One half, measured, is enough. +_ARM_PATHLIB_ACCESSOR = """ + import pathlib as _pathlib + + if hasattr(_pathlib, "_NormalAccessor"): + _pathlib._NormalAccessor.realpath = staticmethod(_no_realpath) +""" + +_DENY_REALPATH = ( + """ + import errno, os, os.path + from pathlib import Path as _ProbePath + + def _no_realpath(*args, **kwargs): + raise FileNotFoundError(errno.ENOENT, "No such file or directory") + + # os.path IS posixpath/ntpath, so on 3.11+ this reaches pathlib's own + # resolve() too. On 3.10 it does not — see _ARM_PATHLIB_ACCESSOR. + os.path.realpath = _no_realpath +""" + + _ARM_PATHLIB_ACCESSOR + + _CONTROL_PROBE +) + +PORTABLE_WORLDS = { + # Denying os.getcwd is @memory's construction. On POSIX it reaches + # Path.resolve(); on Windows it reaches ntpath.realpath, which calls + # os.getcwd() unconditionally on its first lines, before it even checks + # whether the path is absolute. + "getcwd-denied": _INJECT_DEAD_CWD, + # Denying os.path.realpath is the call the Windows CI traceback actually + # died in: inspect.stack() -> getsourcefile() -> getmodule() -> an + # UNPROTECTED os.path.realpath(f). ADDED 2026-08-31 after the Windows gate + # found a defect one line ABOVE the code the first fix guarded. On POSIX the + # os.getcwd denial can never reach that call site — posixpath.abspath raises + # first, inside getabsfile(), where inspect catches it + # (`except (TypeError, FileNotFoundError): return None`) — which is exactly + # why this was invisible on Linux for as long as it existed. Denying the + # call the defect makes is the honest way to run CI's world from here. + "realpath-denied": _DENY_REALPATH, +} + +_NO_WORLD_AT_ALL = ( + """ + from pathlib import Path as _ProbePath +""" + + _CONTROL_PROBE +) + + +def _in_dead_cwd(root: Path, world: str, tail: str, cwd: Path): + """Run `tail` in a child whose cwd read is broken by `world`.""" + return _run( + f""" + import sys + sys.path.insert(0, {str(root)!r}) + {textwrap.indent(textwrap.dedent(world).strip(), " " * 8).lstrip()} + {textwrap.indent(textwrap.dedent(tail).strip(), " " * 8).lstrip()} + """, + cwd=cwd, + ) + + +def _require_live_world(result, world: str = "getcwd-denied"): + """Skip rather than pass when the faked world did not actually break. + + Takes the world name because the two worlds go inert for entirely different + reasons and only one of them is a reason to skip. Until 2026-08-31 this + skipped citing the getcwd/Windows explanation no matter which world ran, and + that cost a real measurement: on Python 3.10 the realpath-denied world was + inert for a CURABLE reason (the captured pathlib accessor), and every pin + using it skipped quietly under a message about os.getcwd and + _getfullpathname. One assert-shaped pin went red on CI; the skip-shaped ones + said nothing at all. A skip has to name its own cause or it hides the cases + worth fixing. + """ + lines = result.stdout.split() + if "CONTROL_DEAD" in lines: + if world == "realpath-denied": + pytest.fail( + "the realpath-denied world went inert, and there is no known " + "platform reason for that one — os.path.realpath is denied " + "directly and the captured-accessor route is patched for " + "<=3.10. This is a bug in the world, not a platform difference: " + f"{result.stdout!r} {result.stderr[-400:]}" + ) + pytest.skip( + "world not exercised: the injected os.getcwd failure does not reach " + "Path.resolve() on this platform (Windows resolves through " + "_getfullpathname, not os.getcwd), so nothing is claimed either way" + ) + assert "CONTROL_LIVE" in lines, ( + f"the child never reported whether its world took:\n{result.stdout}\n{result.stderr}" + ) + return lines + + +# ============================================================================= +# The realpath denial has to arm on every interpreter, not just this one +# ============================================================================= + +# 3.10 pathlib.py:358 did `realpath = staticmethod(os.path.realpath)` on its +# accessor, at pathlib's first import. Nothing on this machine runs 3.10, so +# the CI leg that found this is not reproducible here by asking the +# interpreter — it is reproducible by REBUILDING the property on 3.12, which is +# @memory's falsifiability rule: an interpreter difference you cannot run is +# still a difference you can construct. +# +# ROUND 7, and this is the correction that matters: the first version of that +# rebuild WORKED ONLY ON THE INTERPRETER IT WAS WRITTEN ON. It rerouted +# Path.resolve by replacing `PurePosixPath._flavour` with a wrapper around the +# posixpath MODULE, which is a 3.12 shape and only a 3.12 shape. CI on +# c82c3d34 convicted it on 3.10, 3.11, 3.13 and windows at once. +# +# THE TABLE BELOW WAS READ, NOT INFERRED — CPython Lib/pathlib.py per branch, +# fetched and grepped this session, because round 6's lesson was bought with a +# wrong mechanism that sounded right (mine), and my first write-up of this one +# was wrong too: I had 3.13 routing resolve through `parser`. It does not. +# +# 3.10 resolve -> `self._accessor.realpath(self, strict=strict)` (:1071) +# accessor captured EAGERLY at import: `realpath = +# staticmethod(os.path.realpath)` (:358), `_normal_accessor` (:361), +# `Path._accessor = _normal_accessor` (:954). _flavour is a +# _PosixFlavour OBJECT (:928 -> :274) carrying parse_parts (:56). +# 3.11 resolve -> `os.path.realpath(self, strict=strict)` (:992) — MODULE +# level, dynamic, no accessor at all. _flavour is still an OBJECT +# (:840). So 3.11's red was never about the route: the emulation died +# while PARSING, because a wrapper around the posixpath module has no +# parse_parts. Same cause as 3.10's red, different half of the file. +# 3.12 resolve -> `self._flavour.realpath(...)`, and _flavour IS the +# posixpath module. The only version the old emulation matched. +# 3.13 pathlib became a package; _local.py:670 is +# `self.with_segments(os.path.realpath(self, strict=strict))` and the +# string `_flavour` appears ZERO times in it (`parser = os.path` at +# :104 is for parsing only). Writing _flavour was a write nothing read, +# so the real realpath answered and the bare rebinding ARMED. +# nt the host instantiates WindowsPath, which is not in the PosixPath +# hierarchy the emulation patched, and its flavour IS the live os.path +# module. Same inertness, same armed bare rebinding. +# +# So the emulation now assumes NOTHING about how the host spells its routing. +# It touches exactly two things — a module-level accessor of its own making, +# and `Path.resolve` on the class the host actually instantiates — and it +# replaces no host object, which is what took 3.10/3.11 down. Where the +# interpreter HAS the accessor natively (real <=3.10), it emulates nothing and +# the native one is measured instead: emulating over the real thing would grade +# my stand-in on the only interpreters that carry the defect for real. +# ROUND 8 — THE SAME SPECIES, ONE LEVEL UP: the HARNESS was 3.12-shaped. +# +# The emulation above stopped assuming an interpreter; the machinery that +# INSTALLS it did not. CI on 68ab5132 convicted it on five legs at once, and +# every failure was a stand-in that could only run where it was written: +# +# * 3.10 and 3.11 hosts — the shape preludes reached +# `pathlib.PurePath._parse_path`, which is 3.12+ (those versions spell it +# `_parse_args`). The child died at line 16 before printing anything, so 37 +# tests failed saying "the child never reported whether its route armed" — +# a harness crash wearing the arming probe's message. +# * a 3.10 host also failed the 3.12-as-it-is row of the accessor pin, +# because the EXPECTATION was keyed on the SHAPE'S NAME rather than on what +# the host actually is. On 3.10 every row is ACCESSOR_NATIVE and that is +# correct. +# * 3.13 (and the coverage leg, which also runs 3.13 — ci.yml:101) — the +# nt-absolute getcwd row. The probe built its own input with +# os.path.abspath AFTER the world denied os.getcwd; 3.13's ntpath.isabs +# stopped accepting a rooted-driveless path, so abspath reached the cwd +# where 3.12 short-circuited. The probe died constructing its argument. +# * windows — the posix rows of the getcwd table ran BARE, so on an nt host +# they measured nt under a posix label (@memory's rule, exactly), and the +# drive row asserted a posix answer for a "here" hard-coded to Linux. +# +# THE RULE THIS FILE NOW FOLLOWS, and it is one rule: every stand-in states +# what it needs from the host, reports whether it took, and stands down where +# the host already IS the thing it stands in for. Nothing is keyed on a version +# number or on a shape's name; everything is keyed on a fact the child measures +# and prints. A test whose stand-in cannot be built here SKIPS with the child's +# own reason instead of failing on someone else's interpreter. + +# The probe path, built ONCE and before any world is installed. +# +# Round 8's 3.13 red was the probe dying while constructing its own input. A +# probe whose argument is computed inside the world it measures cannot report +# on that world — and it fails in the most misleading way available, by +# printing nothing at all. +_BUILD_PROBE_PATH = """ + import ntpath, os + + _ARM_ABS = {probe_literal} + + if not os.path.isabs(_ARM_ABS): + print("PROBE_NOT_ABS", _ARM_ABS) + if os.path.sep == ntpath.sep and not os.path.splitdrive(_ARM_ABS)[0]: + # Keyed on the ACTIVE path module, not on os.name, so it fires under + # the windows emulation as well as on the real runner. + print("PROBE_NO_DRIVE", _ARM_ABS) + + # Published, not just checked: on 3.12 ntpath.isabs still accepted a + # rooted-driveless path ("LEGACY BUG", ntpath.py:101), so a missing drive + # changed nothing here and only showed up on 3.13. A value the test can + # read is falsifiable on every version. + print("PROBE_PATH", _ARM_ABS) +""" + +# The emulation decides by MEASUREMENT, not by hasattr. +# +# hasattr(pathlib, "_NormalAccessor") answers "does an accessor exist", which +# is not the question. The question is "does Path.resolve actually travel +# through one", and a host shape can make those two disagree — a 3.10 host with +# the 3.13 shape installed HAS an accessor that resolve no longer consults. +# Measuring it also gives the pin its second, independent line: the child +# reports what it FOUND (ROUTE_WAS_NATIVE / ROUTE_WAS_DARK) separately from +# what it DID (ACCESSOR_NATIVE / ACCESSOR_EMULATED), and the two must agree. +_EMULATE_310 = """ + import os, os.path, pathlib + + _route_seen = [] + if hasattr(pathlib, "_NormalAccessor"): + _orig_accessor_realpath = pathlib._NormalAccessor.realpath + + def _route_probe(*a, **k): + _route_seen.append(True) + return _orig_accessor_realpath(*a, **k) + + pathlib._NormalAccessor.realpath = staticmethod(_route_probe) + try: + pathlib.Path(_ARM_ABS).resolve() + except OSError: + pass + finally: + pathlib._NormalAccessor.realpath = staticmethod(_orig_accessor_realpath) + + print("ROUTE_WAS_NATIVE" if _route_seen else "ROUTE_WAS_DARK") + + if _route_seen: + # Real <=3.10, or a shape that supplied the property. Emulating over it + # would grade my stand-in on the only interpreter that carries the + # defect for real. + print("ACCESSOR_NATIVE") + else: + class _NormalAccessor310: + # EAGER capture — the whole mechanism. Evaluated at class creation, + # exactly as 3.10 evaluated it at pathlib's first import. Make this + # a lazy lookup and the emulation stops emulating 3.10, which is + # what test_the_bare_rebinding_is_inert_under_the_emulation pins. + realpath = staticmethod(os.path.realpath) + + pathlib._NormalAccessor = _NormalAccessor310 + pathlib._normal_accessor = _NormalAccessor310() + + def _resolve_310(self, strict=False): + # 3.10's Path.resolve, in one line: the accessor lookup is dynamic + # (that is why patching it cures) while the value it holds was + # captured eagerly (that is why the bare os.path rebinding is not). + return type(self)(self._accessor.realpath(self, strict=strict)) + + # Patched on Path — the base of PosixPath AND WindowsPath — because the + # class the host instantiates is the only one that matters, and naming + # the posix half is exactly how this went dark on the Windows runner. + pathlib.Path._accessor = pathlib._normal_accessor + pathlib.Path.resolve = _resolve_310 + print("ACCESSOR_EMULATED") +""" + +# The emulation PROVES it took before anything downstream claims anything. +# +# This is the round-6 arming lesson applied one level up. On 3.13 and on +# windows the old emulation was silently inert: the pins failed for a reason +# that took a CI log and a traceback to name, when the child could simply have +# said so. A world that cannot report ROUTE_DARK is a world that reports every +# inert run as a measurement. +_ARM_THE_ROUTE = """ + import pathlib + + _seen = [] + _captured = pathlib._NormalAccessor.realpath + + def _recording(*a, **k): + _seen.append(a[0] if a else None) + return _captured(*a, **k) + + # CLASS only: an instance attribute left behind by the arming probe would + # shadow the class patch the shipped world installs later, and the world + # would go inert with nothing to show for it. + pathlib._NormalAccessor.realpath = staticmethod(_recording) + pathlib.Path(_ARM_ABS).resolve() + pathlib._NormalAccessor.realpath = staticmethod(_captured) + + print("ROUTE_ARMED" if _seen else "ROUTE_DARK") +""" + +# The version CI convicted, kept verbatim and PUBLISHED as a negative control. +# +# Deleting it would leave the host shapes below unfalsifiable: preludes that +# nothing fails against are preludes nobody can tell are working. This one +# fails against each of them, in the exact shape its CI leg reported. +_EMULATION_THAT_ASSUMED_ONE_INTERPRETER = """ + import os.path, pathlib, posixpath + + class _NormalAccessor310: + realpath = staticmethod(os.path.realpath) + + pathlib._NormalAccessor = _NormalAccessor310 + pathlib._normal_accessor = _NormalAccessor310() + + class _EagerFlavour: + def __init__(self, mod): + self._mod = mod + + def __getattr__(self, name): + return getattr(self._mod, name) + + def realpath(self, path, strict=False): + return pathlib._normal_accessor.realpath(str(path)) + + _flav = _EagerFlavour(posixpath) + pathlib.PurePosixPath._flavour = _flav + pathlib.PosixPath._flavour = _flav + print("ROUTE_WAS_DARK") + print("ACCESSOR_EMULATED") +""" + +# ============================================================================= +# Host shapes, built from HALVES that each answer for themselves +# ============================================================================= +# +# Each half installs one property of a real interpreter, and each reports one +# of three verdicts: +# +# HALF::NATIVE the host already has it — install nothing +# HALF::INSTALLED the stand-in is in place +# HALF::UNAVAILABLE: this host cannot carry the stand-in +# +# UNAVAILABLE is a SKIP, not a failure, and it carries the child's own reason — +# because "3.13 has no _flavour to stand in for" and "the stand-in is broken" +# look identical from a red test and are not the same thing at all. + +# ROUND 9 — THE CROSS TERMS. Every stand-in below was written as +# "3.12 plus a delta" and validated on a 3.12 box. On a real 3.10, 3.11, 3.13 or +# nt host it composes with a base it was never written against and produces a +# CHIMERA: a posix flavour stand-in on a host that constructs WindowsPath, a +# symptom table keyed to a flavour that is not there, a simulation of a property +# the host already has. The board's remaining reds were all cross terms. +# +# The rule, which is the three-verdict vocabulary pointed at the stand-ins +# themselves: a stand-in declares which REAL hosts it can speak from, measured +# in the child, and says UNAVAILABLE with its own reason everywhere else. The +# host is a fact to be measured, never a delta from the one I happen to run. +_REPORT_HOST = """ + import os, pathlib + + _flavour_of_host = getattr(pathlib.PurePosixPath, "_flavour", None) + if _flavour_of_host is None: + _FLAVOUR_KIND = "absent" + elif hasattr(_flavour_of_host, "parse_parts"): + _FLAVOUR_KIND = "object" + else: + _FLAVOUR_KIND = "module" + + _CONCRETE_KIND = "nt" if os.name == "nt" else "posix" + _HAS_ACCESSOR = hasattr(pathlib, "_NormalAccessor") + + print("HOST", _FLAVOUR_KIND, _CONCRETE_KIND, "accessor" if _HAS_ACCESSOR else "no-accessor") +""" + +_HALF_FLAVOUR_OBJECT = """ + import pathlib, posixpath + + _host_flavour = getattr(pathlib.PurePosixPath, "_flavour", None) + if _CONCRETE_KIND == "nt": + # The stand-in and its parse hook are POSIX: they replace + # PurePosixPath._flavour and route PurePath._parse_path through it. On a + # host that constructs WindowsPath neither is on the path the child + # travels, and installing them anyway hands the hook an ntpath module + # with no parse_parts. Round 9's windows reds, in one clause. + print("HALF:flavour-object:UNAVAILABLE:this host constructs WindowsPath; the stand-in is posix-only") + elif _host_flavour is None: + print("HALF:flavour-object:UNAVAILABLE:this host has no _flavour at all (3.13+)") + elif hasattr(_host_flavour, "parse_parts"): + print("HALF:flavour-object:NATIVE") + elif not hasattr(pathlib.PurePath, "_parse_path"): + print("HALF:flavour-object:UNAVAILABLE:no _parse_path to route parsing through") + else: + class _FlavourObject: + sep = posixpath.sep + altsep = "" + + def __getattr__(self, name): + return getattr(posixpath, name) + + def parse_parts(self, parts): + return ("", posixpath.sep, list(parts)) + + pathlib.PurePosixPath._flavour = _FlavourObject() + pathlib.PosixPath._flavour = pathlib.PurePosixPath._flavour + + _real_parse = pathlib.PurePath._parse_path.__func__ + + def _parse_path_via_flavour(cls, path): + cls._flavour.parse_parts([path]) + return _real_parse(cls, path) + + pathlib.PurePath._parse_path = classmethod(_parse_path_via_flavour) + print("HALF:flavour-object:INSTALLED") +""" + +_HALF_ACCESSOR_ROUTE = """ + import os, os.path, pathlib + + if hasattr(pathlib, "_NormalAccessor"): + print("HALF:accessor-route:NATIVE") + else: + class _NormalAccessorShape: + realpath = staticmethod(os.path.realpath) + + pathlib._NormalAccessor = _NormalAccessorShape + pathlib._normal_accessor = _NormalAccessorShape() + + def _resolve_via_accessor(self, strict=False): + return type(self)(self._accessor.realpath(self, strict=strict)) + + pathlib.Path._accessor = pathlib._normal_accessor + pathlib.Path.resolve = _resolve_via_accessor + print("HALF:accessor-route:INSTALLED") +""" + +_HALF_DIRECT_RESOLVE = """ + import os, pathlib + + def _resolve_direct(self, strict=False): + return type(self)(os.path.realpath(self, strict=strict)) + + pathlib.Path.resolve = _resolve_direct + print("HALF:direct-resolve:INSTALLED") +""" + +_HALF_FOREIGN_CONCRETE_CLASS = """ + import os.path, pathlib + + # MEASURED, so the copy below is not tidied away: deleting it kills the + # shape (the class becomes unconstructible), while changing the BASE class + # or routing resolve through os.path directly changes nothing on 3.12 — + # both are equivalents here, because the copy is what makes this class + # foreign and on 3.12 the copied flavour IS os.path. + _base_flavour = getattr(pathlib.PurePosixPath, "_flavour", None) + + class _ForeignPath(pathlib.PurePath): + # Deliberately OUTSIDE the PosixPath hierarchy, the way WindowsPath is, + # and carrying the host's own flavour so it is constructible on every + # version. The flavour is copied at class creation, so a later patch of + # PurePosixPath._flavour cannot reach it — which is the whole point. + if _base_flavour is not None: + _flavour = _base_flavour + + def resolve(self, strict=False): + # Through the flavour when the flavour IS a path module, the way + # WindowsPath does on 3.12 — that is what makes the copied flavour + # load-bearing: inherit the posix one instead of copying it and the + # emulation's patch reaches this class, which is the foreignness the + # shape exists to reproduce. + # + # ROUND 9: on 3.10 and 3.11 the flavour is a _PosixFlavour OBJECT + # with no realpath at all, and this line died with + # "'_PosixFlavour' object has no attribute 'realpath'". A stand-in + # that assumes what a host attribute IS, rather than asking, is the + # same defect as assuming the host version. + _mod = getattr(type(self), "_flavour", None) + if not hasattr(_mod, "realpath"): + _mod = os.path + return type(self)(_mod.realpath(self, strict=strict)) + + pathlib.Path = _ForeignPath + print("HALF:foreign-concrete-class:INSTALLED") +""" + +# CI's 3.11 red, in a chunk: a flavour that is an OBJECT and has no realpath. +# The real _PosixFlavour is exactly this — it carries parsing and nothing else — +# and the foreign-class shape read it as a path module and died with +# "'_PosixFlavour' object has no attribute 'realpath'". My own stand-in cannot +# reproduce that: it forwards every unknown attribute to posixpath, so it HAS a +# realpath and the assumption stays invisible. An emulation that is friendlier +# than the thing it emulates hides the defect it was built to find. +_A_FLAVOUR_OBJECT_THAT_IS_NOT_A_PATH_MODULE = """ + import pathlib, posixpath + + class _FlavourWithoutRealpath: + sep = posixpath.sep + altsep = "" + + def __getattr__(self, name): + if name in ("realpath", "abspath"): + raise AttributeError(f"'_PosixFlavour' object has no attribute '{name}'") + return getattr(posixpath, name) + + def parse_parts(self, parts): + return ("", posixpath.sep, list(parts)) + + pathlib.PurePosixPath._flavour = _FlavourWithoutRealpath() + pathlib.PosixPath._flavour = pathlib.PurePosixPath._flavour + print("FLAVOUR_WITHOUT_REALPATH_INSTALLED") +""" + + +_HOST_SHAPES = { + # 3.12 as it really is here: no halves at all, so a shape can be compared + # against whatever interpreter the file is actually running on. + "3.12-as-it-is": (), + # 3.10: resolve travels through an eagerly-captured accessor, and _flavour + # is an OBJECT reached during PARSING. + "3.10-native-accessor-and-flavour-object": (_HALF_FLAVOUR_OBJECT, _HALF_ACCESSOR_ROUTE), + # 3.11: flavour is still an OBJECT, but resolve calls os.path.realpath at + # MODULE level and there is no accessor. Its CI red was the parsing half, + # not the route — which is why both halves are named separately. + "3.11-flavour-object-and-direct-resolve": (_HALF_FLAVOUR_OBJECT, _HALF_DIRECT_RESOLVE), + # 3.13: no accessor, no _flavour on the resolve path. + "3.13-no-accessor-direct-resolve": (_HALF_DIRECT_RESOLVE,), + # windows: the class the host instantiates is not in the PosixPath + # hierarchy, and its realpath is the live os.path one. + "nt-concrete-class-is-not-posix": (_HALF_FOREIGN_CONCRETE_CLASS,), +} + +# The shapes the OLD emulation must die against. "3.12-as-it-is" is not among +# them, and that absence is the finding: the old emulation passed here for two +# rounds because the only interpreter it was ever run against was the one it +# assumed. +# +# Requiring a NAMED symptom rather than "it failed somehow" is what keeps the +# shapes honest: without it, a shape that reproduced only HALF an interpreter +# would still convict — and both flavour-object shapes did exactly that in the +# first cut, with their parsing halves decorative and unmeasured. +# +# What the OLD emulation dies of is a function of the COMPOSED world, not of the +# shape alone — round 9's clearest cross term. It replaces PurePosixPath._flavour +# with a module wrapper, so: +# +# * parse_parts fires only where parsing actually travels through that object: +# a posix-constructed class whose flavour is an OBJECT, native (3.10/3.11) +# or stood in for (3.12 + the flavour half). +# * everywhere else the write is inert or harmless and the bare rebinding +# reaches the real realpath instead — PATHLIB_ARMED. +# * the foreign-class shape is always the second case, because the class it +# constructs carries its OWN flavour copy and the emulation's write cannot +# reach it. That is the whole point of that shape. +# +# The old table hard-coded parse_parts for two shape NAMES, which was right on a +# 3.12 box and wrong on every other host: on a real 3.13 there is no flavour to +# replace, and on a real 3.11 the 3.13 shape inherits an object flavour and dies +# of parse_parts after all. +_FOREIGN_CLASS_SHAPE = "nt-concrete-class-is-not-posix" + + +def _expected_old_emulation_symptom(flavour_in_force: str, shape: str) -> str: + """Two rows, each read off a CI traceback rather than reasoned about. + + The old emulation installs an eager accessor of its own AND replaces + PurePosixPath._flavour with an eager wrapper. So it loses in exactly two + ways, and which one depends on the COMPOSED world: + + parse_parts — parsing travels through the flavour, i.e. the flavour in + force is an OBJECT (native on 3.10/3.11, stood in for on 3.12) and the + child is constructing a posix class. The wrapper is a module wrapper + with no parse_parts, so the child dies at Path(...) before resolve is + ever reached. CI 3.11, verbatim: "module 'posixpath' has no attribute + 'parse_parts'" out of pathlib.py:502 _parse_args. + + ROUTE_DARK — everywhere else. Each shape puts the resolve route somewhere + the emulation's accessor is not: the accessor half binds Path._accessor + to ITS instance, the direct-resolve half bypasses accessors entirely, + and the foreign class is not in the hierarchy at all. The arming probe + patches pathlib._NormalAccessor.realpath — the emulation's own class — + and nothing calls it. + + What the old table pinned instead was PATHLIB_ARMED / PATHLIB_INERT, which + is the INCIDENTAL half: whether a bare os.path.realpath rebinding reaches + the route afterwards. That answer moves with the host for reasons that have + nothing to do with the emulation — 3.13 + the accessor shape gives INERT, + the same shape one version down gives ARMED, and on Windows the foreign + class resolves through posixpath while os.path IS ntpath, so the rebinding + cannot reach it and INERT comes back there too. Three CI reds, one wrong + question. + """ + if flavour_in_force == "object" and shape != _FOREIGN_CLASS_SHAPE: + return "parse_parts" + return "ROUTE_DARK" + + +def _host_line(result) -> list: + """The child's own HOST fingerprint: flavour kind, concrete kind, accessor. + + The LAST one, deliberately. A child that simulates a foreign host re-reports + afterwards, and what every half downstream decided against is the host in + force at that point — not the box the process happened to start on. + """ + fingerprints = [line for line in result.stdout.splitlines() if line.startswith("HOST ")] + return fingerprints[-1].split()[1:] if fingerprints else [] + + +def _flavour_in_force(result) -> str: + """object / module / absent — after every half has had its say.""" + if "HALF:flavour-object:INSTALLED" in result.stdout: + return "object" + if "HALF:flavour-object:NATIVE" in result.stdout: + return "object" + host = _host_line(result) + return host[0] if host else "unknown" + + +_SHAPES_THAT_CONVICT_THE_OLD_EMULATION = sorted(set(_HOST_SHAPES) - {"3.12-as-it-is"}) + +_BARE_REBINDING_ONLY = """ + import errno, os, os.path + from pathlib import Path as _ProbePath + + def _no_realpath(*args, **kwargs): + raise FileNotFoundError(errno.ENOENT, "No such file or directory") + + os.path.realpath = _no_realpath +""" + +_PATHLIB_ROUTE_PROBE = """ + from pathlib import Path as _RoutePath + + try: + _RoutePath(_ARM_ABS).resolve() + except OSError: + print("PATHLIB_ARMED") + else: + print("PATHLIB_INERT") +""" + +# Same probe, relative input. @skills' round-7 finding, answered by measurement +# rather than by agreement: cwd and resolve ride DIFFERENT captured attributes +# on <=3.10, so arming one says nothing about the other. +_PATHLIB_ROUTE_PROBE_RELATIVE = """ + from pathlib import Path as _RoutePath + + try: + _RoutePath("relative_thing").resolve() + except OSError: + print("PATHLIB_ARMED") + else: + print("PATHLIB_INERT") +""" + + +# ONE spelling of the probe path, substituted into the fragment that builds it, +# so no two fragments can disagree about what "absolute" means — everything +# downstream reuses the value, never the expression. +# +# Built from the host's own anchor rather than from os.sep alone: os.sep + +# "definitely" is DRIVE-RELATIVE on nt (@drone's sibling failure printed +# "RESOLVED: D:\\tmp"), and ntpath.realpath resolves such a path against the +# current drive — reading the cwd for a literal that looks absolute. abspath of +# the anchor is "/" on posix and "D:\\" on the runner. +_PROBE_LITERAL = 'os.path.join(os.path.abspath(os.sep), "definitely", "not", "here")' + + +def _compose(*chunks: str) -> str: + """Join child-script fragments at column zero, skipping empty ones.""" + return "\n".join( + textwrap.dedent(chunk).strip().replace("{probe_literal}", _PROBE_LITERAL) for chunk in chunks if chunk.strip() + ) + + +def _child_script(*chunks: str) -> str: + """Every child starts with the host fingerprint. One rule, no exceptions. + + Round 9: each half now decides what it can honestly do from the host it + finds itself on, so _FLAVOUR_KIND / _CONCRETE_KIND / _HAS_ACCESSOR have to + exist before ANY chunk runs — including a half used as a simulation prelude, + which is composed ahead of the shape it stands in for. Putting the + fingerprint in _shape_prelude was one call site short of that, and the child + died with NameError before a single half reported. + """ + return _compose(_REPORT_HOST, *chunks) + + +def _shape_prelude(shape: str) -> str: + """The halves of a host shape, composed in order.""" + return _compose(*_HOST_SHAPES[shape]) + + +def _unavailable_half(result) -> str: + """The child's own reason, when a stand-in cannot be built on this host.""" + for line in result.stdout.splitlines(): + if line.startswith("HALF:") and ":UNAVAILABLE:" in line: + return line + return "" + + +class TestTheRealpathDenialArmsOnEveryInterpreter: + """The 3.10 CI red, reproduced and cured without a 3.10 on the machine. + + FOUND BY CI, not here: the 3.10 leg of 8550ed10 failed this file's + direct-call pin with "os.path.realpath denial was inert in the child, so + this run claims nothing" — the arming probe refusing to make a claim, which + is the instrument working rather than breaking. 3.11-3.13 were green. + + Worth stating because it is the uncomfortable half: that probe reached the + pathlib route because I changed it to, one session earlier, on the argument + that a probe should ask through the route the code under test travels. The + argument was right and the comment I wrote next to it named this exact + hazard — "if pathlib ever bound realpath eagerly instead of looking it up on + the module". On <=3.10 it does. The reasoning predicted the failure and the + implementation shipped into it anyway. + + ROUND 7 (@devpulse, c82c3d34): the CURE then failed on four legs of its own + — 3.10, 3.11, 3.13 and windows — because the emulation built to survive one + interpreter difference was written against a second one. Every test here now + runs under each HOST SHAPE, so "arms on every interpreter" is measured + against five of them rather than promised by a class name. + """ + + def _child(self, shape: str, world: str, probe: str = _PATHLIB_ROUTE_PROBE): + return _run( + _child_script( + _shape_prelude(shape), + _BUILD_PROBE_PATH, + _EMULATE_310, + _ARM_THE_ROUTE, + world, + probe, + ), + cwd=Path(__file__).parent, + ) + + def _armed(self, result, shape: str) -> list: + """Refuse to read a verdict out of a child whose route never armed. + + A stand-in the host cannot carry is a SKIP carrying the child's own + reason. Round 8's 3.10 and 3.11 legs failed 37 tests under the arming + probe's message when what had actually happened was a shape prelude + crashing on plumbing those versions do not have — the message named the + instrument that never ran instead of the one that broke. + """ + missing = _unavailable_half(result) + if missing: + pytest.skip(f"host shape {shape!r} cannot be built here: {missing}") + + lines = result.stdout.split() + assert "PROBE_NOT_ABS" not in lines, ( + f"the probe literal is not absolute on this runner ({shape}) — an " + "absolute path is whatever os.path.isabs says it is, and a " + f"drive-relative one reads the cwd instead:\n{result.stdout}" + ) + assert "ROUTE_DARK" not in lines, ( + f"the emulation did not take under host shape {shape!r}: Path.resolve " + "never reached the accessor, so nothing below this line measures " + f"anything.\n{result.stdout}\n{result.stderr}" + ) + assert "ROUTE_ARMED" in lines, ( + f"the child never reported whether its route armed ({shape}):\n{result.stdout}\n{result.stderr}" + ) + return lines + + @pytest.mark.parametrize("shape", sorted(_HOST_SHAPES)) + def test_the_emulation_says_which_accessor_it_measured(self, shape): + """Native or stand-in — the child names it, so no run is ambiguous. + + The real-3.10 shape is the one with a claim in it: emulating on top of a + native accessor would grade my stand-in on the only interpreters that + carry the defect for real. + """ + lines = self._armed(self._child(shape, _BARE_REBINDING_ONLY), shape) + + # NOT keyed on the shape's name, and not on a version: keyed on what + # the child FOUND. On a 3.10 host every row is ACCESSOR_NATIVE and that + # is correct — the round-8 red here was an expectation that only knew + # how to be right on 3.12. + found_native = "ROUTE_WAS_NATIVE" in lines + decided_native = "ACCESSOR_NATIVE" in lines + decided_emulated = "ACCESSOR_EMULATED" in lines + + assert decided_native != decided_emulated, f"the child reported neither decision, or both:\n{lines}" + assert decided_native == found_native, ( + f"the child found the accessor route {'live' if found_native else 'dark'} " + f"and then did the opposite ({shape!r}): emulating over a live accessor " + f"grades the stand-in instead of the interpreter.\n{lines}" + ) + + @pytest.mark.parametrize("shape", sorted(_HOST_SHAPES)) + def test_the_bare_rebinding_is_inert_under_the_emulation(self, shape): + """CI's 3.10 failure, reproduced on this interpreter. + + Doubles as the eager-capture pin: a published emulated shape needs + something that fails when the emulation stops emulating. If + _NormalAccessor310 captured lazily, the rebinding would reach it and + this would report PATHLIB_ARMED. + """ + result = self._child(shape, _BARE_REBINDING_ONLY) + lines = self._armed(result, shape) + + assert "PATHLIB_INERT" in lines, ( + "rebinding os.path.realpath was expected to be INERT against an " + f"eagerly-captured accessor under host shape {shape!r} — if this " + f"armed, the emulation is no longer emulating 3.10:\n{result.stdout}\n{result.stderr}" + ) + + @pytest.mark.parametrize("shape", sorted(_HOST_SHAPES)) + def test_the_shipped_world_arms_the_pathlib_route_under_the_emulation(self, shape): + """The cure, measured against the same emulation.""" + result = self._child(shape, _DENY_REALPATH) + lines = self._armed(result, shape) + + assert "PATHLIB_ARMED" in lines, ( + "the shipped realpath-denied world did not reach Path.resolve() " + f"through an eagerly-captured accessor under host shape {shape!r} — " + "the _ARM_PATHLIB_ACCESSOR half is what makes this work on <=3.10:" + f"\n{result.stdout}\n{result.stderr}" + ) + + @pytest.mark.parametrize("shape", _SHAPES_THAT_CONVICT_THE_OLD_EMULATION) + def test_the_previous_emulation_dies_against_the_shape_it_assumed_away(self, shape): + """The negative control for the host shapes themselves. + + A prelude nothing fails against is a prelude nobody can tell is + working. The emulation CI convicted is kept above and run here: it must + fail to deliver a usable verdict under every shape but the one it was + written on. If this ever goes green, the shape stopped reproducing the + interpreter and the pins above are measuring 3.12 five times. + """ + result = _run( + _child_script( + _shape_prelude(shape), + _BUILD_PROBE_PATH, + _EMULATION_THAT_ASSUMED_ONE_INTERPRETER, + _ARM_THE_ROUTE, + _BARE_REBINDING_ONLY, + _PATHLIB_ROUTE_PROBE, + ), + cwd=Path(__file__).parent, + ) + + lines = result.stdout.split() + armed_and_inert = "ROUTE_ARMED" in lines and "PATHLIB_INERT" in lines + assert not armed_and_inert, ( + f"the old emulation survived host shape {shape!r} — the shape is no " + f"longer reproducing the interpreter that convicted it:\n{result.stdout}" + ) + + # Round 9: the symptom is derived from what the child MEASURED, not + # from the shape's name. On a 3.11 box the 3.13 shape inherits an object + # flavour and dies of parse_parts; on a 3.13 box the 3.10 shape has no + # flavour to stand in for and the old emulation goes inert instead. Both + # are the old emulation losing — they just lose differently, and a table + # written on 3.12 called one of them a wrong symptom. + in_force = _flavour_in_force(result) + if in_force == "unknown": + pytest.fail( + f"the child never printed its HOST fingerprint under shape {shape!r}, so " + f"which symptom to expect cannot be derived:\n{result.stdout}\n{result.stderr}" + ) + + symptom = _expected_old_emulation_symptom(in_force, shape) + # Token-matched in stdout, substring in stderr: the emulation prints its + # own fixed ROUTE_WAS_DARK line, and a substring test against stdout + # would be one careless edit away from accepting the emulation's own + # boast as the arming probe's verdict. + # + # PUBLISHED AS EQUIVALENT rather than counted as a kill: restoring the + # substring form passes every row today, because "ROUTE_WAS_DARK" does + # not contain "ROUTE_DARK". Nothing here can tell the two apart, and the + # token form is bought for the next marker that shares a prefix — said + # out loud, because a mutant nobody can kill and a pin nobody wrote look + # identical in a survivor table. + convicted_as_predicted = symptom in result.stdout.split() or symptom in result.stderr + assert convicted_as_predicted, ( + f"host shape {shape!r} with a {in_force!r} flavour in force convicted the old " + f"emulation, but not with the symptom that composition predicts ({symptom!r}) — " + f"it is reproducing some other half of that interpreter:\n{result.stdout}\n{result.stderr}" + ) + + # The recorder replaces the world's `_no_realpath`, so what gets measured is + # the SHIPPED _ARM_PATHLIB_ACCESSOR text and not a second spelling of it in + # this test. + _RECORDING_DENIAL = """ + import os + + _seen_here = [] + + def _no_realpath(*args, **kwargs): + _seen_here.append(args[0] if args else None) + return os.path.join(os.path.abspath(os.sep), "recorded") + """ + + _READ_THE_FIRST_ARG = """ + import pathlib as _pathlib + + _pathlib.Path(_ARM_ABS).resolve() + print("FIRST_ARG", _seen_here[0]) + """ + + @pytest.mark.parametrize("shape", sorted(_HOST_SHAPES)) + def test_the_accessor_patch_receives_the_path_and_not_the_accessor(self, shape): + """@devpulse's trap (a), measured on the shipped text itself. + + A plain function assigned to the accessor CLASS binds on instance + access and eats the path into self. Every denial in this file raises + unconditionally, so it would raise just the same with the wrong + argument — the pin would stay green for a reason that has nothing to do + with what it claims. The only way to see it is to look at what the + patched callable was actually handed, so the recorder here REPLACES the + world's own `_no_realpath` and _ARM_PATHLIB_ACCESSOR is then applied + verbatim: what gets measured is the shipped text, not a second spelling + of it in this test. + """ + result = _run( + _child_script( + _shape_prelude(shape), + _BUILD_PROBE_PATH, + _EMULATE_310, + _ARM_THE_ROUTE, + self._RECORDING_DENIAL, + _ARM_PATHLIB_ACCESSOR, + self._READ_THE_FIRST_ARG, + ), + cwd=Path(__file__).parent, + ) + + lines = self._armed(result, shape) + assert "FIRST_ARG" in lines, f"the recorder never ran ({shape}):\n{result.stdout}\n{result.stderr}" + first_arg = lines[lines.index("FIRST_ARG") + 1] + assert first_arg.endswith("here"), ( + "the accessor patch was handed something other than the path — a " + "plain function on the class binds and eats the path into self, and " + f"a raise-shaped denial can never notice ({shape}): {first_arg!r}" + ) + + def test_an_instance_attribute_would_shadow_the_class_patch(self): + """WHY the world sets no instance half — @drone's finding, rebuilt here. + + I recommended the belt-and-braces pair to @memory one session ago and + was wrong: an instance attribute shadows the class descriptor, so with + both applied the class half can no longer be falsified. Their reply + checked sufficiency in CPython (3.10 pathlib.py:361 builds ONE shared + accessor with no instance attributes, :954 hands it to Path), and this + is the behavioural half of that argument — the mechanism itself, so the + pair cannot quietly come back as an obvious improvement. + """ + result = _run( + _child_script( + _BUILD_PROBE_PATH, + _EMULATE_310, + _ARM_THE_ROUTE, + """ + import errno, os, pathlib as _pathlib + + def _no_realpath(*args, **kwargs): + raise FileNotFoundError(errno.ENOENT, "denied") + + def _untouched(path, *a, **k): + return str(path) + """, + _ARM_PATHLIB_ACCESSOR, + """ + # The shadowing half, added on purpose: a bare instance + # attribute that does NOT deny. + _pathlib._normal_accessor.realpath = _untouched + + try: + _pathlib.Path(_ARM_ABS).resolve() + except OSError: + print("CLASS_PATCH_ANSWERED") + else: + print("INSTANCE_SHADOWED_THE_CLASS") + """, + ), + cwd=Path(__file__).parent, + ) + + assert "INSTANCE_SHADOWED_THE_CLASS" in result.stdout.split(), ( + "an instance attribute no longer shadows the class patch — then the " + "belt-and-braces pair is safe again and the ruling above is stale:" + f"\n{result.stdout}\n{result.stderr}" + ) + + def test_an_inert_realpath_world_fails_rather_than_skipping(self): + """The gate change itself, which nothing else reaches. + + Both worlds report inertness identically (CONTROL_DEAD), so the only + thing separating "platform difference, claim nothing" from "the world is + broken, say so" is which world asked. A skip that can fire for a cause + it does not name is how the 3.10 leg stayed quiet. + """ + + class _FakeResult: + stdout = "CONTROL_DEAD\n" + stderr = "" + + # NOT pytest.raises(Failed): pytest.skip raises Skipped, which sails + # straight through a raises(Failed) block and retires the whole test as + # SKIPPED — green suite, defeat reported as a pass. Caught by mutating + # the gate back to its pre-fix behaviour, which this pin exists to + # convict and initially did not. + try: + _require_live_world(_FakeResult(), "realpath-denied") + except Failed as exc: + assert "no known platform reason" in str(exc), exc + except Skipped: + pytest.fail("the gate SKIPPED an inert realpath-denied world instead of failing it") + else: + pytest.fail("the gate accepted an inert realpath-denied world without complaint") + + with pytest.raises(Skipped): + _require_live_world(_FakeResult(), "getcwd-denied") + + def test_the_arming_probe_can_report_a_route_that_never_armed(self): + """The negative control FOR the arming probe. + + My own round-4 lesson, and it applies to this file's newest instrument: + a CONTROL_LIVE probe that cannot say NO turns every pin downstream + vacuously green. ROUTE_ARMED is now load-bearing in _armed(), so + something has to prove the child can print ROUTE_DARK. This restores the + real resolve after the emulation installed its own — the route is + genuinely bypassed, and the probe must notice. + """ + result = _run( + _child_script( + _BUILD_PROBE_PATH, + _EMULATE_310, + """ + import os, pathlib + + def _resolve_around_the_accessor(self, strict=False): + return type(self)(os.path.realpath(self)) + + pathlib.Path.resolve = _resolve_around_the_accessor + """, + _ARM_THE_ROUTE, + ), + cwd=Path(__file__).parent, + ) + + assert "ROUTE_DARK" in result.stdout.split(), ( + "the arming probe reported ARMED for a route that goes nowhere near " + f"the accessor — it cannot say NO:\n{result.stdout}\n{result.stderr}" + ) + + def test_the_accessor_patch_is_a_no_op_where_there_is_no_accessor(self): + """hasattr-guarded, so 3.11+ is untouched rather than version-checked. + + Without the emulation there is no _NormalAccessor on 3.11+, so the + guarded block must do nothing and the world must still arm through the + call-time lookup that those versions use. + """ + result = _run( + _child_script( + _BUILD_PROBE_PATH, + _DENY_REALPATH, + """ + import pathlib + print("ACCESSOR_ABSENT" if not hasattr(pathlib, "_NormalAccessor") else "ACCESSOR_PRESENT") + """, + _PATHLIB_ROUTE_PROBE, + ), + cwd=Path(__file__).parent, + ) + + lines = result.stdout.split() + if "ACCESSOR_PRESENT" in lines: + pytest.skip("this interpreter has the accessor; the no-op claim is not testable here") + assert "PATHLIB_ARMED" in lines, ( + f"the world stopped arming on an interpreter with no accessor:\n{result.stdout}\n{result.stderr}" + ) + + +# ============================================================================= +# The harness itself, run on a host that is not the one it was written on +# ============================================================================= +# +# Round 8's reds were not in the emulation — they were in the machinery that +# installs it. These simulations make this 3.12 interpreter present the plumbing +# of the hosts that convicted it, so "the harness survives a host that is not +# 3.12" is a measurement here rather than a promise checked by CI. +# +# The scope is part of the simulation, not an afterthought: shadowing a pathlib +# class is a blunt instrument, and pointing it at a shape whose halves do not +# read that class produces a failure about the simulation rather than about the +# harness. Saying which shapes a simulation speaks for is cheaper than a +# cleverer simulation, and it is honest about what is being claimed. +# +# ROUND 9 asked the same question one level up — which REAL hosts can a +# simulation speak FROM — and CI answered it: on 3.13 the flavour simulation +# presents nothing, because the half it is built out of has nothing to stand in +# for there. Both scopes are declared per simulation now, and both are gates. + + +# Round 10: the host's own PARSING DIALECT, measured rather than versioned. +# +# CI's 3.10 and 3.11 legs died inside the OLD emulation's flavour stand-in, and +# not because the stand-in was wrong: it forwards to the posixpath module, which +# is the thing a 3.12 flavour IS. What differs is the CONSUMER. On 3.10/3.11 +# pathlib's own _parse_args calls `cls._flavour.parse_parts(parts)` — an API +# only the old _Flavour OBJECTS ever had, which no path module has ever +# carried. So a module-shaped flavour cannot serve those hosts no matter what it +# delegates to, and growing it a parse_parts of its own would be a simulation of +# a simulation (@devpulse's call, and I agree — that is a lap, not a cure). +# +# This probe asks the host directly instead of keying on a version: install a +# module-shaped flavour, build a path, and report what the host's pathlib +# reached for. Any row whose world contains a module-shaped flavour declares +# `parsing: module-tolerant` and SKIPS BY NAME with this answer elsewhere. +_REPORT_PARSING_DIALECT = """ + import pathlib, posixpath + + class _ModuleShapedFlavour: + sep = posixpath.sep + altsep = "" + + def __getattr__(self, name): + return getattr(posixpath, name) + + try: + pathlib.PurePosixPath._flavour = _ModuleShapedFlavour() + except (AttributeError, TypeError) as _exc: + print("PARSING", "unknown:" + type(_exc).__name__) + else: + try: + pathlib.PurePosixPath("/parsing/probe/only") + except AttributeError as _exc: + _text = str(_exc) + _asked = _text.rsplit("'", 2)[-2] if _text.count("'") >= 2 else "something" + print("PARSING", "demands:" + _asked) + else: + print("PARSING", "module-tolerant") +""" + + +# 3.10's parsing consumer, reduced to the one call that matters, so the dialect +# probe above has something to say NO against on a 3.12 box. Round 6's rule +# applied to round 10's new instrument: a probe that cannot report the bad news +# turns every gate keyed on it vacuously open. +_A_HOST_WHOSE_PATHLIB_SPEAKS_THE_OBJECT_DIALECT = """ + import pathlib + + class _OldDialectPurePosixPath: + _flavour = getattr(pathlib.PurePosixPath, "_flavour", None) + + def __init__(self, *parts): + # 3.10 pathlib.py:587, in one line: _parse_args asks the FLAVOUR. + type(self)._flavour.parse_parts(list(parts)) + + pathlib.PurePosixPath = _OldDialectPurePosixPath + print("OLD_DIALECT_CONSUMER_INSTALLED") +""" + + +def _measure_host() -> dict: + """Run the child's OWN fingerprint chunk once, and read the answer back. + + The parent could import pathlib and ask these questions in-process. That + would be a SECOND spelling of the host, free to drift from the one every + child actually decides on — the same species as round 7's probe built out + of the live os.path. One chunk, one measurement, both sides of the pipe. + """ + result = _run(_child_script(), cwd=Path(__file__).parent) + host = _host_line(result) + if len(host) != 3: + return {"flavour": "unknown", "concrete": "unknown", "accessor": "unknown"} + return {"flavour": host[0], "concrete": host[1], "accessor": host[2]} + + +def _rows_missing_a_dialect_declaration(source: str, allowed: dict) -> list: + """Which test rows compose the old emulation without declaring their hosts. + + A judgement over source text, so it can be fed a file this one is not — + @commons' rule applied to a structural check. The check that only ever runs + against its own file cannot be shown to fire at all, and a scan that never + reports is indistinguishable from a scan that cannot. + """ + offenders = [] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.FunctionDef) or not node.name.startswith("test"): + continue + names = {inner.id for inner in ast.walk(node) if isinstance(inner, ast.Name)} + if "_EMULATION_THAT_ASSUMED_ONE_INTERPRETER" not in names: + continue + if node.name in allowed: + continue + if "_A_MODULE_SHAPED_FLAVOUR_NEEDS_A_TOLERANT_HOST" not in names: + offenders.append(f"{node.name} (line {node.lineno})") + return offenders + + +def _parsing_answer(result) -> str: + """The child's dialect answer, or "unknown". + + Fails CLOSED, deliberately: a child that said nothing has not told me the + host is tolerant, and defaulting to the convenient word would open every + row this fact gates while looking exactly like a measurement. + """ + for line in result.stdout.splitlines(): + if line.startswith("PARSING "): + return line.split(None, 1)[1] + return "unknown" + + +def _measure_parsing_dialect() -> str: + """What this host's pathlib asks a flavour for while parsing. One child, once.""" + return _parsing_answer(_run(_child_script(_REPORT_PARSING_DIALECT), cwd=Path(__file__).parent)) + + +_HOST_FACTS = _measure_host() +_HOST_FACTS["parsing"] = _measure_parsing_dialect() + +# The requirement a WORLD carries, not a simulation: any row that installs a +# module-shaped flavour needs a host whose pathlib is content with one. Written +# as a fact-keyed dict so it goes through the same judgement as every other +# speakable-hosts declaration in this file. +_A_MODULE_SHAPED_FLAVOUR_NEEDS_A_TOLERANT_HOST = {"parsing": ("module-tolerant",)} + + +def _simulation_unavailable(requires: dict, host: dict) -> str: + """Which REAL hosts a simulation can speak from — the third verdict, one level up. + + Round 9's finding: a simulation is a stand-in too. "Make this box look like + 3.13" is a claim about the box making it, and on a box that is ALREADY 3.13 + — or that constructs WindowsPath, or whose flavour is natively an object — + the simulation either never reaches the condition it exists to present or + presents a different one silently. Then the test downstream measures the + simulation instead of the harness, and its red names the wrong thing. + + A plain function of two dicts, so the rows for hosts this machine is not are + reachable from this machine — @commons' rule, applied to the simulations. + Empty string means it can speak; anything else is the reason it cannot. + """ + for fact in sorted(requires): + allowed = requires[fact] + if host.get(fact) not in allowed: + return ( + f"this simulation can only speak from a host whose {fact} is one of " + f"{sorted(allowed)}; this host reports {host.get(fact)!r}" + ) + return "" + + +# name -> (prelude, the shapes it is meaningful for, the real hosts it can speak from) +_HOST_SIMULATIONS = { + # 3.10 and 3.11 present a flavour that is already an OBJECT. The shape's + # half must recognise that and install nothing — and it must reach that + # decision BEFORE touching _parse_path, which those versions spell + # _parse_args and which is what the child actually died on. + "flavour-is-already-an-object": ( + _HALF_FLAVOUR_OBJECT, + sorted(_HOST_SHAPES), + # It IS the half: on a host with no flavour at all, or one that builds + # WindowsPath, the half honestly reports UNAVAILABLE and the simulation + # presents nothing. CI's 3.13 red was exactly that, read as a defect. + {"flavour": ("object", "module"), "concrete": ("posix",)}, + ), + # 3.10 also presents the accessor natively. + # The accessor half installs onto pathlib.Path itself, which every concrete + # class inherits from on every version — no host precondition to declare. + "accessor-route-is-already-native": (_HALF_ACCESSOR_ROUTE, sorted(_HOST_SHAPES), {}), + # 3.13 has no _flavour at all. Shadowing the class is crude, but the + # condition the half reads is exactly the real one, and the child's own + # parsing is left alone so the run gets far enough to report. + "purposixpath-has-no-flavour": ( + """ + import pathlib + + class _FlavourlessPurePosixPath: + pass + + pathlib.PurePosixPath = _FlavourlessPurePosixPath + """, + [name for name in sorted(_HOST_SHAPES) if _HALF_FLAVOUR_OBJECT in _HOST_SHAPES[name]], + # On an nt host the half refuses for the WindowsPath reason first, so + # the UNAVAILABLE that comes back is not the one being simulated. + {"concrete": ("posix",)}, + ), + # The backstop lane: a host whose flavour is a module AND which does not + # spell parsing the 3.12 way. No released interpreter is both, which is + # why it is simulated rather than waited for. + "purepath-has-no-parse-path": ( + """ + import pathlib + + class _PurePathWithoutParsePath: + pass + + pathlib.PurePath = _PurePathWithoutParsePath + """, + [name for name in sorted(_HOST_SHAPES) if _HALF_FLAVOUR_OBJECT in _HOST_SHAPES[name]], + # The guard this simulation aims at is the LAST branch of the half. A + # host whose flavour is natively an object answers NATIVE two branches + # earlier and never reaches it — so on 3.10 and 3.11 this row was + # measuring nothing while looking green. + {"flavour": ("module",), "concrete": ("posix",)}, + ), +} + +_SIMULATION_PAIRS = sorted( + (simulation, shape) for simulation, (_, shapes, _requires) in _HOST_SIMULATIONS.items() for shape in shapes +) + +# The simulation may itself be built out of halves, and their verdicts are not +# the ones under test — the marker separates the two. +_SIMULATION_DONE = 'print("SIMULATION_INSTALLED")' + +_LEGAL_HALF_VERDICTS = ("NATIVE", "INSTALLED", "UNAVAILABLE") + + +class TestTheHarnessSurvivesAHostThatIsNotTheOneItWasWrittenOn: + """Round 8's actual subject: the stand-ins, not the thing they stand in for. + + 68ab5132 failed 37 tests on the 3.10 and 3.11 legs, every one of them + saying "the child never reported whether its route armed". Not one was + about arming: the shape prelude reached pathlib.PurePath._parse_path, which + is 3.12+, and the child died before printing anything. A harness crash + arrived wearing the arming probe's message, which is the worst way to fail + — the instrument that never ran got the blame. + """ + + def _speak_or_skip(self, simulation: str) -> None: + """One gate, in front of every test in this class. + + A simulation that cannot speak from this host SKIPS with its own reason + — the same rule the halves already follow, and the one round 9's 3.13 + leg proved was not yet wired at this level. + """ + why = _simulation_unavailable(_HOST_SIMULATIONS[simulation][2], _HOST_FACTS) + if why: + pytest.skip(f"{simulation}: {why}") + + def _simulated(self, simulation: str, *chunks: str) -> str: + """The ONE place a simulated host is installed, gated and re-measured. + + Every test in this class composes through here. Written as a helper + rather than repeated because the re-fingerprint below is the kind of + line a second call site quietly does without — and then the test that + would have caught its absence is the one carrying its own copy. + """ + self._speak_or_skip(simulation) + return _child_script( + _HOST_SIMULATIONS[simulation][0], + _SIMULATION_DONE, + # Re-measured: from here on the halves decide against the host the + # simulation is presenting, not the one the process started on. + _REPORT_HOST, + *chunks, + ) + + def _child(self, simulation: str, shape: str): + return _run( + self._simulated( + simulation, + _shape_prelude(shape), + _BUILD_PROBE_PATH, + _EMULATE_310, + _ARM_THE_ROUTE, + _DENY_REALPATH, + _PATHLIB_ROUTE_PROBE, + ), + cwd=Path(__file__).parent, + ) + + @staticmethod + def _shape_halves(result) -> list: + """Only the halves the SHAPE reported — not the simulation's own.""" + lines = result.stdout.splitlines() + if "SIMULATION_INSTALLED" in lines: + lines = lines[lines.index("SIMULATION_INSTALLED") + 1 :] + return [line for line in lines if line.startswith("HALF:")] + + @pytest.mark.parametrize(("simulation", "shape"), _SIMULATION_PAIRS) + def test_every_half_reports_a_verdict_instead_of_crashing(self, simulation, shape): + """The whole round-8 species, in one assertion per pair. + + Whatever the host looks like, each half either takes, stands down, or + says it cannot be built — and the child says which. What it must never + do is die on plumbing it assumed. + """ + result = self._child(simulation, shape) + + halves = self._shape_halves(result) + assert len(halves) == len(_HOST_SHAPES[shape]), ( + f"{len(_HOST_SHAPES[shape])} halves were composed for {shape!r} under " + f"{simulation!r} but {len(halves)} reported — one of them died:" + f"\n{result.stdout}\n{result.stderr}" + ) + for line in halves: + assert line.split(":")[2] in _LEGAL_HALF_VERDICTS, f"illegal half verdict: {line!r}" + + if _unavailable_half(result): + return + + assert "ROUTE_ARMED" in result.stdout.split(), ( + f"every half took under {simulation!r}/{shape!r} and the route still " + f"never armed:\n{result.stdout}\n{result.stderr}" + ) + + def test_a_shape_stands_down_where_the_host_already_has_its_property(self): + """The 3.10-host row: nothing to install, and the child says so.""" + result = self._child("accessor-route-is-already-native", "3.10-native-accessor-and-flavour-object") + + assert "HALF:accessor-route:NATIVE" in result.stdout, result.stdout + assert "ACCESSOR_NATIVE" in result.stdout.split(), ( + "the emulation built a stand-in over a live accessor route — on a " + f"real 3.10 that grades my copy instead of the interpreter:\n{result.stdout}" + ) + + def test_the_flavour_half_decides_before_it_reaches_3_12_only_plumbing(self): + """CI's exact crash, inverted into a pin. + + On 3.10 and 3.11 the flavour is already an object, so the half must + answer NATIVE without ever asking for _parse_path. Reorder those two + checks and this goes red on the simulation below, which is the only + place a 3.12 box can see it. + """ + result = self._child("flavour-is-already-an-object", "3.11-flavour-object-and-direct-resolve") + + # ROUND 10 SWEEP: read the SHAPE's half, not the whole of stdout. The + # simulation is built out of this same half, and on a host that already + # has an object flavour the simulation ALSO prints NATIVE — so the raw + # substring form was satisfiable by the simulation's own line while the + # row under test said anything at all. Round 8's separation existed one + # test over and had not travelled here. + # + # PUBLISHED AS EQUIVALENT: restoring the raw form passes on every host + # reachable from here, because nothing between the two lines can move + # the flavour — they agree by construction today. Kept anyway, because + # an assertion satisfiable by a line other than the one under test reads + # as a measurement of the shape when it is not necessarily one. + assert "HALF:flavour-object:NATIVE" in self._shape_halves(result), ( + f"the SHAPE's flavour half did not stand down:\n{result.stdout}\n{result.stderr}" + ) + assert "Traceback" not in result.stderr, result.stderr + + def test_an_unavailable_half_skips_rather_than_failing(self): + """The gate itself, which no shape on this host can reach. + + Every half takes on 3.12, so the UNAVAILABLE lane is only reachable + through a simulation — and the DECISION it feeds (skip, not fail) is + reachable only by calling the gate. A skip that can silently become a + failure is how round 8's 3.10 leg reported a harness crash as an arming + defect. + """ + + class _Unavailable: + stdout = "HALF:flavour-object:UNAVAILABLE:this host has no _flavour at all\n" + stderr = "" + + class _Fine: + stdout = "HALF:flavour-object:NATIVE\nROUTE_WAS_DARK\nACCESSOR_EMULATED\nROUTE_ARMED\n" + stderr = "" + + instrument = TestTheRealpathDenialArmsOnEveryInterpreter() + + with pytest.raises(Skipped) as skipped: + instrument._armed(_Unavailable(), "some-shape") + assert "UNAVAILABLE" in str(skipped.value), skipped.value + + assert "ROUTE_ARMED" in instrument._armed(_Fine(), "some-shape"), ( + "the gate skipped a child that reported perfectly well" + ) + + @pytest.mark.parametrize( + "shape", + ["3.10-native-accessor-and-flavour-object", "3.11-flavour-object-and-direct-resolve"], + ) + def test_the_old_emulations_symptom_follows_the_host_and_not_the_shape_name(self, shape): + """CI's 3.13 red, reproduced here — the round-9 species end to end. + + The negative control asked the flavour shapes to die of parse_parts. + That is what they do on a box WITH a flavour. Take the flavour away and + the same two shapes convict the same emulation by a different mechanism + entirely: its accessor is no longer on the resolve route, so the arming + probe reports ROUTE_DARK and nothing ever reaches a parse. A table keyed + on the shape's NAME called that "reproducing some other half of that + interpreter" and went red on a leg where everything was working. + + ROUND 10 — WHICH HOSTS THIS ROW CAN SPEAK FROM. The world below contains + the old emulation, and the old emulation installs a MODULE-SHAPED + flavour. On 3.10 and 3.11 pathlib's own _parse_args asks that flavour + for parse_parts, an API no path module ever had, and the child dies at + the first Path(...) before any of this can be measured — which is + exactly the parse_parts symptom this row exists to distinguish itself + from, arriving before the row can run. The premise cannot hold there, so + the row says so and skips, with the answer the host itself gave. + """ + why = _simulation_unavailable(_A_MODULE_SHAPED_FLAVOUR_NEEDS_A_TOLERANT_HOST, _HOST_FACTS) + if why: + pytest.skip(f"the old emulation installs a module-shaped flavour and {why}") + + result = _run( + self._simulated( + "purposixpath-has-no-flavour", + _shape_prelude(shape), + _BUILD_PROBE_PATH, + _EMULATION_THAT_ASSUMED_ONE_INTERPRETER, + _ARM_THE_ROUTE, + _BARE_REBINDING_ONLY, + _PATHLIB_ROUTE_PROBE, + ), + cwd=Path(__file__).parent, + ) + + assert _flavour_in_force(result) == "absent", ( + f"the simulated host did not present as flavourless:\n{result.stdout}\n{result.stderr}" + ) + assert _expected_old_emulation_symptom("absent", shape) == "ROUTE_DARK" + assert "ROUTE_DARK" in result.stdout.split(), ( + f"on a flavourless host the old emulation's accessor is off the resolve " + f"route and the probe must say so:\n{result.stdout}\n{result.stderr}" + ) + assert "parse_parts" not in result.stderr, ( + "parse_parts is the symptom of a host that HAS a flavour — this one " + f"does not, so the old table was pinning a mechanism that cannot fire:\n{result.stderr}" + ) + + def test_the_foreign_class_survives_a_flavour_that_is_not_a_path_module(self): + """CI's 3.11 red on the nt shape, reproduced on 3.12. + + The foreign class resolves THROUGH the flavour it copied, because that + is what WindowsPath does on 3.12 and copying is what makes it foreign. + On 3.10 and 3.11 the flavour is a _PosixFlavour object that knows about + parsing and nothing else, and the line died reaching for realpath on it. + The cure asks instead of assuming — and this is the world where asking + is the difference, since a host flavour with no realpath is a thing this + interpreter never produces on its own. + """ + # No _EMULATE_310 in this chain, and that is the whole test: it replaces + # pathlib.Path.resolve, which on this shape IS the foreign class's own + # resolve. Composed with it, the line under test never runs and the + # mutation that restores the assumption survives — measured, after the + # first cut of this test scored exactly that. + result = _run( + _child_script( + _A_FLAVOUR_OBJECT_THAT_IS_NOT_A_PATH_MODULE, + _BUILD_PROBE_PATH, + _shape_prelude("nt-concrete-class-is-not-posix"), + _DENY_REALPATH, + _PATHLIB_ROUTE_PROBE, + ), + cwd=Path(__file__).parent, + ) + + assert "FLAVOUR_WITHOUT_REALPATH_INSTALLED" in result.stdout.split(), ( + f"the world never took, so this measured nothing:\n{result.stdout}\n{result.stderr}" + ) + assert "has no attribute 'realpath'" not in result.stderr, ( + "the foreign class is reading its copied flavour as a path module " + f"again — this is CI's 3.11 traceback, verbatim:\n{result.stderr}" + ) + assert "HALF:foreign-concrete-class:INSTALLED" in result.stdout, result.stdout + assert "PATHLIB_ARMED" in result.stdout.split(), ( + f"the shape stopped arming under a realpath-less flavour:\n{result.stdout}\n{result.stderr}" + ) + + def test_the_fingerprint_reads_the_host_instead_of_asserting_one(self): + """The fingerprint is load-bearing for four gates now, so pin its source. + + The behavioural row — the child agrees with this process about os.name — + is only falsifiable on a host where the two could differ, which from + Linux means never. So the claim that matters is structural and is made + here: the concrete kind is DERIVED from os.name, not written down. That + is the difference between a stand-in and a decoration, and it is exactly + the mutation (a hard-coded "posix") that no behavioural pin on this box + can catch. + """ + result = _run(_child_script(), cwd=Path(__file__).parent) + + assert _host_line(result)[1] == ("nt" if os.name == "nt" else "posix"), result.stdout + assert "os.name" in _REPORT_HOST, ( + "the fingerprint stopped reading the host — every gate keyed on concrete kind is now measuring a literal" + ) + assert "_CONCRETE_KIND = " in _REPORT_HOST and '"nt" if' in _REPORT_HOST, _REPORT_HOST + + @staticmethod + def _flavour_half_verdict(*chunks: str) -> str: + """The half's own last word, from a child composed exactly as given.""" + result = _run(_child_script(*chunks, _HALF_FLAVOUR_OBJECT), cwd=Path(__file__).parent) + lines = [line for line in result.stdout.splitlines() if line.startswith("HALF:")] + assert lines, f"the half reported nothing at all:\n{result.stdout}\n{result.stderr}" + return lines[-1] + + def test_the_flavour_half_refuses_a_host_that_builds_windows_paths(self): + """CI's windows red, reproduced by faking the FINGERPRINT — not a world. + + @trigger's rule from round 8, applied: what convicted the runner was the + platform the process runs on, and a probe reads that through one name. + This substitutes that name and nothing else, deliberately — building a + whole WindowsPath world here would measure my scaffolding, while the + thing under test is which branch the half takes when the host says nt. + + Speakable from every host, and that is not luck: the nt branch is the + half's FIRST question, so it is reached before anything version-shaped. + """ + refusal = self._flavour_half_verdict('_CONCRETE_KIND = "nt"') + + assert refusal.startswith("HALF:flavour-object:UNAVAILABLE:"), refusal + assert "WindowsPath" in refusal, f"the refusal does not name what it is refusing for: {refusal!r}" + + # The second row is round 10's red, reproduced on a 3.12 box: a host whose + # flavour is ALREADY an object answers NATIVE, which is what 3.10 and 3.11 + # said on CI while this test demanded the literal INSTALLED. + _CONTROL_HOSTS = { + "as-this-host-is": (), + "with-an-object-flavour-already-installed": (_HALF_FLAVOUR_OBJECT,), + } + + @pytest.mark.parametrize("control_host", sorted(_CONTROL_HOSTS)) + def test_forcing_the_posix_branch_leaves_the_row_where_the_host_put_it(self, control_host): + """The control for the refusal above, and round 10's first red. + + This asserted the literal HALF:flavour-object:INSTALLED — the 3.12 + answer, spelled. Three legs disagreed HONESTLY: 3.10 and 3.11 have an + object flavour natively and say NATIVE, 3.13 has none and says + UNAVAILABLE. The verdict machinery was right on every one of them and + the assertion was still 3.12-shaped, which is the round-9 species moved + up one level, into the assertion OVER the verdict. + + The control's real question needs no literal at all: does forcing the + posix branch leave the row where the untouched host put it, while + forcing nt moves it? Both sides measured, nothing spelled — and run + twice, once against a host presenting the answer CI's 3.10 and 3.11 + legs gave, so the cure is falsifiable here instead of on the board. + """ + if _HOST_FACTS["concrete"] != "posix": + pytest.skip( + "this row forces the POSIX branch, which installs the very " + "stand-in the nt branch exists to refuse; from an nt host that " + f"is not a control, it is the defect (host: {_HOST_FACTS['concrete']})" + ) + + prelude = self._CONTROL_HOSTS[control_host] + control = self._flavour_half_verdict(*prelude) + forced_posix = self._flavour_half_verdict(*prelude, '_CONCRETE_KIND = "posix"') + forced_nt = self._flavour_half_verdict(*prelude, '_CONCRETE_KIND = "nt"') + + assert forced_posix == control, ( + "forcing the branch the host is already on MOVED the verdict — then " + f"the fingerprint substitution is doing more than it claims: {forced_posix!r} vs {control!r}" + ) + assert forced_nt != control, ( + "the nt refusal is what this host says anyway, so the row above is " + f"not measuring the platform branch at all: {forced_nt!r} vs {control!r}" + ) + + def test_the_parsing_dialect_probe_can_report_the_answer_that_shuts_a_row(self): + """The negative control for round 10's new instrument. + + Every skip added this round is keyed on one measured word. If the probe + could only ever say "module-tolerant", those rows would run everywhere + and the 3.10/3.11 crash would come back wearing a different message. So + this box grows 3.10's parsing consumer for one child and the probe has + to notice — and to NAME what was asked, because "this host is different" + and "this host wants parse_parts" are not the same report. + """ + result = _run( + _child_script(_A_HOST_WHOSE_PATHLIB_SPEAKS_THE_OBJECT_DIALECT, _REPORT_PARSING_DIALECT), + cwd=Path(__file__).parent, + ) + + assert "OLD_DIALECT_CONSUMER_INSTALLED" in result.stdout.split(), ( + f"the old-dialect consumer never took:\n{result.stdout}\n{result.stderr}" + ) + answers = [line for line in result.stdout.splitlines() if line.startswith("PARSING ")] + assert answers == ["PARSING demands:parse_parts"], ( + "the dialect probe cannot tell a host that speaks the object dialect " + f"from one that does not — every round-10 skip is vacuous:\n{result.stdout}\n{result.stderr}" + ) + + assert _simulation_unavailable( + _A_MODULE_SHAPED_FLAVOUR_NEEDS_A_TOLERANT_HOST, {"parsing": "demands:parse_parts"} + ), "and the judgement over that answer must close the row" + + def test_a_simulation_this_host_cannot_carry_skips_rather_than_failing(self, monkeypatch): + """The gate's DECISION, reachable from a host that is none of those. + + CI's 3.13 leg failed here rather than skipping, and a failure that + should have been a skip is how round 8 spent a round blaming an arming + probe for a crash in the prelude ahead of it. + """ + # Patched on the module OBJECT, not by dotted name: this file is + # collected under two different rootdirs and its import name differs + # between them. A string target would work in one and fail in the other. + monkeypatch.setattr( + sys.modules[__name__], + "_HOST_FACTS", + {"flavour": "absent", "concrete": "posix", "accessor": "no-accessor"}, + raising=True, + ) + + with pytest.raises(Skipped) as skipped: + self._speak_or_skip("flavour-is-already-an-object") + assert "flavour" in str(skipped.value), skipped.value + + self._speak_or_skip("accessor-route-is-already-native") + + @pytest.mark.parametrize("simulation", ["purposixpath-has-no-flavour", "purepath-has-no-parse-path"]) + def test_a_host_that_cannot_carry_the_stand_in_says_so_by_name(self, simulation): + """UNAVAILABLE is a skip with a reason, not a red on someone else's box.""" + result = self._child(simulation, "3.10-native-accessor-and-flavour-object") + + missing = _unavailable_half(result) + assert missing.startswith("HALF:flavour-object:UNAVAILABLE:"), ( + f"the half neither installed nor explained itself under {simulation!r}:\n{result.stdout}\n{result.stderr}" + ) + assert missing.split(":", 3)[3].strip(), f"UNAVAILABLE with no reason: {missing!r}" + + +class TestTheRoundNineJudgementsAnswerForHostsThisBoxIsNot: + """The judgements, separated from the worlds that feed them — @commons' rule. + + Every row below is a host this machine cannot be: a 3.13 with no _flavour, a + 3.10 whose flavour is natively an object, a Windows box, and a fourth thing + no interpreter has ever been. Round 9's reds all lived in the CROSS of host + and shape, and a cross is only measurable here if the deciding is a plain + function of measured values rather than something that happens inside a + child on one leg of the matrix. + """ + + @pytest.mark.parametrize( + ("flavour_in_force", "shape", "expected"), + [ + # Hosts WITH an object flavour: parsing travels through the wrapper + # and the child dies constructing a Path. 3.10 and 3.11 natively, + # 3.12 once the flavour half has stood one in. + ("object", "3.10-native-accessor-and-flavour-object", "parse_parts"), + ("object", "3.11-flavour-object-and-direct-resolve", "parse_parts"), + # THE CROSS TERM, measured on CI's 3.11 leg: the shape named after + # 3.13 still dies of parse_parts, because the HOST supplies the + # flavour the shape never mentions. + ("object", "3.13-no-accessor-direct-resolve", "parse_parts"), + # ...except against the foreign class, which carries its own copy + # and is the one shape the emulation's write cannot reach. + ("object", "nt-concrete-class-is-not-posix", "ROUTE_DARK"), + # THE OTHER CROSS TERM, measured on CI's 3.13 leg: no flavour to + # replace, so the emulation loses by being off the resolve route. + ("absent", "3.10-native-accessor-and-flavour-object", "ROUTE_DARK"), + ("absent", "3.11-flavour-object-and-direct-resolve", "ROUTE_DARK"), + ("absent", "nt-concrete-class-is-not-posix", "ROUTE_DARK"), + # A module flavour is 3.12 and the Windows runner both — the write + # lands somewhere real and changes nothing that resolve reads. + ("module", "3.10-native-accessor-and-flavour-object", "ROUTE_DARK"), + ("module", "3.13-no-accessor-direct-resolve", "ROUTE_DARK"), + # No interpreter reports this. It is here so that an unrecognised + # fingerprint fails to the mechanism that is always true rather than + # to the one that happens to be listed first. + ("chimera", "3.10-native-accessor-and-flavour-object", "ROUTE_DARK"), + ], + ) + def test_the_symptom_is_a_function_of_the_host_and_the_shape(self, flavour_in_force, shape, expected): + assert _expected_old_emulation_symptom(flavour_in_force, shape) == expected + + _A_313 = {"flavour": "absent", "concrete": "posix", "accessor": "no-accessor"} + _A_312 = {"flavour": "module", "concrete": "posix", "accessor": "no-accessor"} + _A_310 = {"flavour": "object", "concrete": "posix", "accessor": "accessor"} + _WINDOWS = {"flavour": "module", "concrete": "nt", "accessor": "no-accessor"} + _UNREADABLE = {"flavour": "unknown", "concrete": "unknown", "accessor": "unknown"} + + @pytest.mark.parametrize( + ("simulation", "host", "speaks"), + [ + # The flavour simulation IS the flavour half: it needs a host with a + # flavour to stand in for, and a posix concrete class to install it + # on. CI's 3.13 red, and its windows red, in two rows. + ("flavour-is-already-an-object", _A_312, True), + ("flavour-is-already-an-object", _A_310, True), + ("flavour-is-already-an-object", _A_313, False), + ("flavour-is-already-an-object", _WINDOWS, False), + # The parse_path backstop aims at the LAST branch of that half. A + # host whose flavour is already an object answers two branches + # earlier, so on 3.10 and 3.11 this row measured nothing while + # looking green — the species one level below a false green. + ("purepath-has-no-parse-path", _A_312, True), + ("purepath-has-no-parse-path", _A_310, False), + ("purepath-has-no-parse-path", _A_313, False), + # The accessor simulation declares no preconditions: pathlib.Path + # exists and is inherited from on every version and both platforms. + ("accessor-route-is-already-native", _A_313, True), + ("accessor-route-is-already-native", _WINDOWS, True), + ("accessor-route-is-already-native", _UNREADABLE, True), + # A host that could not be measured is not a host any simulation + # with a precondition may claim to speak from. + ("flavour-is-already-an-object", _UNREADABLE, False), + ("purposixpath-has-no-flavour", _UNREADABLE, False), + ], + ) + def test_a_simulation_declares_which_real_hosts_it_can_speak_from(self, simulation, host, speaks): + why = _simulation_unavailable(_HOST_SIMULATIONS[simulation][2], host) + + assert (why == "") is speaks, f"{simulation} on {host}: {why!r}" + if not speaks: + assert "this host reports" in why, f"a refusal with no measurement in it: {why!r}" + + @pytest.mark.parametrize( + ("parsing", "speaks"), + [ + ("module-tolerant", True), + # 3.10 and 3.11: pathlib's own _parse_args asks the flavour for + # parse_parts, which no path module has ever had. + ("demands:parse_parts", False), + # A host that asks for something nobody has named yet is still a + # host a module-shaped flavour cannot serve. + ("demands:something", False), + # And an unmeasurable one fails CLOSED: an instrument that cannot + # tell what the host speaks must not assume the convenient answer. + ("unknown:TypeError", False), + ("unknown", False), + ], + ) + def test_a_module_shaped_flavour_declares_which_hosts_it_can_serve(self, parsing, speaks): + """Round 10's judgement, with every host this box is not. + + The rule is not about versions: it is about whether the host's pathlib + speaks to a flavour in a dialect a path MODULE knows. Delegation cannot + cure a consumer that calls methods the delegate never had — which is why + the cure is a declaration and a skip, not a shim. + """ + why = _simulation_unavailable(_A_MODULE_SHAPED_FLAVOUR_NEEDS_A_TOLERANT_HOST, {"parsing": parsing}) + + assert (why == "") is speaks, f"{parsing}: {why!r}" + if not speaks: + assert parsing in why, f"the refusal does not carry the host's own answer: {why!r}" + + # The one row that may meet a host whose pathlib speaks the object dialect, + # and why — published rather than silently exempted. Any other test that + # composes the old emulation must DECLARE its speakable hosts. + _ROWS_THAT_MAY_MEET_AN_OBJECT_DIALECT_HOST = { + "test_the_previous_emulation_dies_against_the_shape_it_assumed_away": ( + "its expectation IS the crash: the symptom judgement predicts " + "parse_parts on exactly the hosts whose pathlib asks for it, and the " + "expectation is derived from the flavour the CHILD measured" + ), + } + + def test_every_world_with_a_module_shaped_flavour_declares_or_derives(self): + """The sweep, kept from rotting — @skills' lower-bound rule as a pin. + + Two tests were flagged by CI. A flagged list is a diff and therefore a + LOWER BOUND, so the rule is stated structurally instead: any test that + composes the old emulation DECLARES which hosts it can speak from, and + the single exception is named below with its reason. A third such test + written next month gets caught here rather than on a board. + + The first cut of this check accepted "mentions the symptom judgement" + as equivalent to declaring — and a mutant that deleted the cross-term + row's declaration SURVIVED it, because that row calls the judgement with + a literal argument. Mentioning a measurement is not making one. + """ + offenders = _rows_missing_a_dialect_declaration( + Path(__file__).read_text(encoding="utf-8"), self._ROWS_THAT_MAY_MEET_AN_OBJECT_DIALECT_HOST + ) + + assert not offenders, ( + "these tests install a module-shaped flavour without saying which " + "hosts can carry one and without deriving the symptom: " + ", ".join(offenders) + ) + + def test_the_dialect_sweep_reports_a_row_that_does_not_declare(self): + """The negative control for the sweep — it has to be able to say YES. + + A structural check run only against a file that already passes is the + vacuous-green species in a new costume. This feeds it a source it has + never seen, containing one row that declares and one that does not. + """ + source = textwrap.dedent( + """ + def test_it_declares(): + _run(_child_script(_EMULATION_THAT_ASSUMED_ONE_INTERPRETER)) + _simulation_unavailable(_A_MODULE_SHAPED_FLAVOUR_NEEDS_A_TOLERANT_HOST, _HOST_FACTS) + + def test_it_does_not(): + _run(_child_script(_EMULATION_THAT_ASSUMED_ONE_INTERPRETER)) + + def test_it_never_touches_the_old_emulation(): + _run(_child_script(_DENY_REALPATH)) + """ + ) + + offenders = _rows_missing_a_dialect_declaration(source, {}) + + assert [name.split(" (")[0] for name in offenders] == ["test_it_does_not"], offenders + assert _rows_missing_a_dialect_declaration(source, {"test_it_does_not": "published reason"}) == [], ( + "the allow-list is not being consulted" + ) + + def test_the_published_exception_carries_its_reason(self): + """An exemption with no reason is an exemption nobody will re-examine.""" + for name, reason in self._ROWS_THAT_MAY_MEET_AN_OBJECT_DIALECT_HOST.items(): + assert reason.strip(), f"{name} is exempt with no reason given" + assert name in Path(__file__).read_text(encoding="utf-8"), ( + f"{name} is exempt and no longer exists — a stale exemption is a hole" + ) + + def test_the_platform_emulations_declare_an_asymmetry_and_not_an_oversight(self): + """Why one platform stand-in has a precondition and the other does not. + + The posix row turns on whether the path READS as absolute, which the + concrete class gets a vote on — so a host that builds WindowsPath cannot + run it, and CI's windows leg proved that by answering ARMED where the + row says INERT. The nt row turns on ntpath.realpath reading os.getcwd + UNCONDITIONALLY, above any question of spelling, so no dialect mismatch + can move it and it can be measured from anywhere. + + Pinned because "the nt emulation has no requirements" looks exactly like + "nobody got round to writing them". + """ + posix_requires = _PLATFORM_EMULATIONS["posix"][1] + nt_requires = _PLATFORM_EMULATIONS["nt"][1] + + assert _simulation_unavailable(posix_requires, self._WINDOWS) != "" + assert _simulation_unavailable(posix_requires, self._A_312) == "" + assert _simulation_unavailable(posix_requires, self._A_313) == "" + assert _simulation_unavailable(nt_requires, self._WINDOWS) == "" + assert _simulation_unavailable(nt_requires, self._A_312) == "" + + class _Result: + def __init__(self, stdout): + self.stdout = stdout + self.stderr = "" + + def test_the_flavour_in_force_is_read_from_the_child_not_assumed(self): + """INSTALLED and NATIVE both mean object; otherwise the host answers.""" + installed = self._Result("HOST module posix no-accessor\nHALF:flavour-object:INSTALLED\n") + native = self._Result("HOST object posix accessor\nHALF:flavour-object:NATIVE\n") + refused = self._Result("HOST absent posix no-accessor\nHALF:flavour-object:UNAVAILABLE:no _flavour\n") + windows = self._Result("HOST module nt no-accessor\nHALF:flavour-object:UNAVAILABLE:WindowsPath\n") + + assert _flavour_in_force(installed) == "object" + assert _flavour_in_force(native) == "object" + assert _flavour_in_force(refused) == "absent" + assert _flavour_in_force(windows) == "module" + + def test_a_child_that_never_reported_its_dialect_is_unknown_not_tolerant(self): + """Round 10's fact reads the same way its neighbours do: closed on silence.""" + assert _parsing_answer(self._Result("PARSING module-tolerant\n")) == "module-tolerant" + assert _parsing_answer(self._Result("PARSING demands:parse_parts\n")) == "demands:parse_parts" + assert _parsing_answer(self._Result("HOST module posix no-accessor\n")) == "unknown" + assert _simulation_unavailable( + _A_MODULE_SHAPED_FLAVOUR_NEEDS_A_TOLERANT_HOST, {"parsing": _parsing_answer(self._Result(""))} + ), "silence must close the rows this fact gates, not open them" + + def test_a_child_that_never_reported_its_host_is_unknown_not_guessed(self): + """The fingerprint is load-bearing now, so its absence must be loud.""" + assert _flavour_in_force(self._Result("HALF:direct-resolve:INSTALLED\n")) == "unknown" + assert _host_line(self._Result("")) == [] + + def test_the_last_fingerprint_wins_because_a_simulation_moves_the_host(self): + """A simulated host re-reports; the halves ran against the SECOND line.""" + both = self._Result("HOST module posix no-accessor\nSIMULATION_INSTALLED\nHOST absent posix no-accessor\n") + + assert _host_line(both) == ["absent", "posix", "no-accessor"] + assert _flavour_in_force(both) == "absent" + + def test_this_host_measured_itself_before_any_world_touched_it(self): + """The parent's own fingerprint, and the gate that reads it.""" + assert set(_HOST_FACTS) == {"flavour", "concrete", "accessor", "parsing"} + assert _HOST_FACTS["concrete"] in ("posix", "nt"), _HOST_FACTS + assert _HOST_FACTS["flavour"] in ("object", "module", "absent"), _HOST_FACTS + # Round 10's fact. Either the host is content with a module-shaped + # flavour, or it NAMES what it asked for — "unknown" would mean the + # probe could not even install one, which is a fifth state nobody has + # seen and would take every world built on it dark without saying why. + parsing = _HOST_FACTS["parsing"] + assert parsing == "module-tolerant" or parsing.startswith("demands:"), parsing + + +def _drive_row_is_satisfied(os_name: str, anchor_drive: str) -> bool: + """Does this host's ntpath.abspath answer the way its own row says it must? + + A plain function of two values, so every row is reachable from any machine + — including the ones this box will never be. The world that supplies those + values is one test below; the decision is here. + """ + if os_name == "nt": + return bool(anchor_drive) + if os_name == "posix": + return anchor_drive == "" + return False + + +class TestTheProbeLiteralNamesAnAbsolutePathOnTheRunner: + """@devpulse's trap (b), split into the half I can measure and the half I cannot. + + The trap: a POSIX-absolute literal like os.sep + "tmp" is DRIVE-RELATIVE on + nt, and ntpath.realpath resolves it against the current drive — so a path + that looks absolute reads the cwd, and round 4's headline (ntpath.realpath + calls os.getcwd unconditionally) applies to it after all. + + MEASURED HERE: what makes a path drive-relative, which is pure ntpath and + runs on any OS. NOT MEASURABLE HERE, and said rather than guessed: + ntpath.abspath on posix has no drive to add, so this machine cannot produce + the cured literal's Windows value. That row is answered by the runner — the + child prints PROBE_NO_DRIVE — instead of being asserted from Linux. + """ + + def test_a_rooted_driveless_literal_carries_no_drive_on_nt(self): + rooted = ntpath.join(ntpath.sep, "definitely", "not", "here") + + assert ntpath.splitdrive(rooted)[0] == "", ( + f"the mechanism this file guards against stopped being true: {rooted!r}" + ) + assert ntpath.splitdrive(ntpath.join("D:" + ntpath.sep, "definitely"))[0] == "D:" + + def test_the_child_builds_its_literal_from_the_anchor_not_from_the_separator(self): + """The cure, pinned where it is spelled — there is exactly one spelling.""" + assert "abspath(os.sep)" in _PROBE_LITERAL, _PROBE_LITERAL + + @pytest.mark.parametrize( + ("os_name", "anchor_drive", "expected"), + [ + ("posix", "", True), + ("posix", "D:", False), + ("nt", "D:", True), + ("nt", "", False), + # No machine produces this, which is exactly why it is here: + # @commons' rule, that the judgement must be reachable for inputs + # the host cannot manufacture. + ("plan9", "", False), + ], + ) + def test_the_drive_judgement_answers_for_hosts_this_one_is_not(self, os_name, anchor_drive, expected): + """The judgement, separated from the world that feeds it. + + Round 8 red: the live version of this pin asserted "ntpath.abspath + produces no drive" with `here` hard-coded to a posix runner. On the + Windows host it produced D:, the assertion fired, and its message said + the row had "just become falsifiable" — true, and the pin's own + conclusion arriving as a failure. Split this way, the nt row is + reachable from Linux and the plan9 row is reachable from anywhere. + """ + assert _drive_row_is_satisfied(os_name, anchor_drive) is expected + + def test_the_live_host_satisfies_its_own_drive_row(self): + """And the world, fed to the judgement above.""" + anchor_drive = ntpath.splitdrive(ntpath.abspath(ntpath.sep))[0] + + assert _drive_row_is_satisfied(os.name, anchor_drive), ( + f"host {os.name!r} produced anchor drive {anchor_drive!r}, which its " + "own row does not allow — on posix that means ntpath grew a drive " + "and the PROBE_NO_DRIVE gate is exercisable locally after all; on nt " + "it means the cured probe literal cannot carry one" + ) + + +# ntpath.realpath's win32 branch, the only half of it this table needs, read +# from CPython's Lib/ntpath.py rather than remembered. THE FALSIFIABLE PART IS +# THE ORDERING: `cwd = os.getcwd()` runs UNCONDITIONALLY, several lines ABOVE +# the `if not had_prefix and not isabs(path)` that would use it. On posix that +# module's realpath is just abspath, which reads the cwd only for a relative +# path — so the two platforms genuinely disagree, and a pin that asserts the +# posix answer everywhere is red on the runner. +# +# LINE NUMBERS AS A DATED COURTESY, not as the claim — @skills' correction, and +# the same species as trigger's round-5 line-scoped waiver: on 3.12.3 the read +# is at :673 in the str branch (:541 in the bytes branch) with the isabs check +# at :687, while an earlier reading of mine said :678 from another 3.12.x. The +# ordering survives a patch release; the number fails open and silent on a CI +# leg one bugfix along, and the fleet briefly had two citizens citing two +# numbers for one mechanism with no way for a third reader to tell which was +# stale. +# +# The abspath half is emulated too, and round 8 is why. win32 resolves a +# rooted-driveless path against the CURRENT DRIVE via _getfullpathname; posix +# ntpath has no drive to find, so it falls back to reading os.getcwd — and on +# 3.13 it reaches that fallback for a rooted path where 3.12 short-circuited +# (ntpath.isabs dropped the "LEGACY BUG" rooted-is-absolute case, 3.12:102 vs +# 3.13:95). That is what killed the nt-absolute row on the 3.13 and coverage +# legs: the probe was building its own argument through this function, inside +# the world that had already denied the cwd. Supplying a drive makes the +# emulated platform answer the way the real one does, on every version. +_WINDOWS_EMULATED = """ + import ntpath, os, sys + + _FAKE_DRIVE = "C:" + + def _win32_abspath(path): + path = ntpath.normpath(os.fspath(path)) + if not ntpath.splitdrive(path)[0]: + if path.startswith(ntpath.sep): + path = _FAKE_DRIVE + path + else: + path = ntpath.join(_FAKE_DRIVE + ntpath.sep, path) + return path + + def _win32_realpath(path, *, strict=False): + path = ntpath.normpath(os.fspath(path)) + cwd = os.getcwd() + if not ntpath.isabs(path): + path = ntpath.join(cwd, path) + return path + + ntpath.abspath = _win32_abspath + ntpath.realpath = _win32_realpath + os.path = ntpath + os.sep = ntpath.sep + sys.modules["os.path"] = ntpath +""" + +# The other half of @memory's rule, and round 8's windows red: emulate BOTH +# platforms or neither. A "posix" row that installs nothing is not a posix row +# — it is the HOST, wearing a posix label, and on the Windows runner it +# measured nt and disagreed with its own table. +_POSIX_EMULATED = """ + import os, posixpath, sys + + os.path = posixpath + os.sep = posixpath.sep + sys.modules["os.path"] = posixpath +""" + +# Windows' contribution to the posix-absolute row, reduced to the one property +# that decides it: the concrete class spells the path back in ITS dialect, so a +# posix-absolute literal reaches posixpath.realpath as a relative path. +# +# MEASURED THE HARD WAY, and the reason this patches a dunder instead of +# introducing a class: a stand-in built as `class X(pathlib.Path)` and installed +# as `pathlib.Path = X` is DOWNGRADED BY THE INTERPRETER. Path.__new__ +# (3.12 pathlib.py:1166) asks `if cls is Path` — and `Path` there is a GLOBAL +# LOOKUP in the pathlib module, which the installation just rebound to X. So +# `cls is Path` is true for X, __new__ returns a plain PosixPath, __init__ never +# runs because the result is not an instance of X, and the first str() dies in +# _load_parts with no _raw_paths. The round-9 species one level further down: +# replacing a module attribute changes what the interpreter's OWN identity test +# means. Pinned below so it cannot come back as a tidier-looking rewrite. +_A_CONCRETE_CLASS_THAT_SPELLS_ANOTHER_DIALECT = """ + import pathlib + + _real_fspath = pathlib.PurePath.__fspath__ + + def _fspath_in_another_dialect(self): + return _real_fspath(self).replace("/", chr(92)) + + pathlib.PurePath.__fspath__ = _fspath_in_another_dialect + print("DIALECT_INSTALLED") +""" + + +# ROUND 9, the windows red on the posix-absolute row: a platform emulation is a +# stand-in too, and this pair is NOT symmetric. +# +# The posix emulation repoints os.path, os.sep and sys.modules["os.path"] — but +# pathlib's concrete class is chosen at import from os.name, and on Windows it +# stays WindowsPath. So the child builds a genuinely posix-absolute literal +# ("/definitely/not/here"), hands it to Path(), and Path spells it back in ITS +# dialect — "\\definitely\\not\\here" — which posixpath then reads as RELATIVE and +# resolves against the cwd. The row said INERT, the runner said ARMED, and both +# were right about different worlds: the emulation was a chimera, posix os.path +# with an nt concrete class. +# +# The nt emulation is not symmetric with it, and the asymmetry is the mechanism +# rather than luck: its row rests on ntpath.realpath reading os.getcwd +# UNCONDITIONALLY, above any question of what the path looks like. A dialect +# mismatch cannot change that verdict, so the nt rows can be measured from a +# posix host — while the posix rows, which turn entirely on whether the path +# reads as absolute, cannot be measured from an nt one. +# +# So: same three-verdict vocabulary as the halves and the simulations, one level +# out. A platform emulation says which real hosts it can speak from, and a row +# it cannot speak for SKIPS with that reason instead of asserting a POSIX fact +# on a Windows box. +_PLATFORM_EMULATIONS = { + "posix": (_POSIX_EMULATED, {"concrete": ("posix",)}), + "nt": (_WINDOWS_EMULATED, {}), +} + +# route -> {absolute probe verdict, relative probe verdict} under a getcwd +# denial. EVERY row is measured through an explicit platform emulation, on +# whatever host runs the file. +_GETCWD_TABLE = { + ("posix", "absolute"): "PATHLIB_INERT", + ("posix", "relative"): "PATHLIB_ARMED", + ("nt", "absolute"): "PATHLIB_ARMED", + ("nt", "relative"): "PATHLIB_ARMED", +} + + +class TestTheGetcwdRouteNeedsNoAccessorPatch: + """@skills' round-7 return finding, answered by measurement. + + Their words: cwd and resolve ride DIFFERENT captured attributes on <=3.10 + (Path.cwd() is `cls(cls._accessor.getcwd())` there — confirmed, 3.10 + pathlib.py:993, and 3.11 :907 already reads os.getcwd directly), so an + emulation that exercises one call leaves the other patch dark. + + What I measured in my own tree rather than agreeing: the getcwd-denied + world needs no accessor patch AT ALL for the route this file cares about, + because Path.resolve reaches os.getcwd from INSIDE realpath, which looks the + name up on the `os` module at CALL time on every interpreter. Nothing + captures it. What the world genuinely cannot reach on <=3.10 is Path.cwd(), + and the template guard never calls it — a limit, stated rather than left to + be discovered. + + AND THE ROW @memory'S LITMUS CAUGHT BEFORE CI DID: run the probe under the + OPPOSITE platform's emulation and require the verdict not to move. It + moved. The absolute-path row is a POSIXPATH fact; on nt, realpath reads the + cwd before it looks at the path at all, so the same denial arms there. + + ROUND 8 finished the same thought in the other direction: the posix rows + were running BARE, so on an nt host they measured nt under a posix label. + Both rows go through an explicit emulation now — emulate both platforms or + neither, which is the rule that produced this class in the first place. + """ + + @staticmethod + def _emulation_or_skip(route: str) -> str: + """One gate for every row that installs a platform, and the only one.""" + emulation, requires = _PLATFORM_EMULATIONS[route] + why = _simulation_unavailable(requires, _HOST_FACTS) + if why: + pytest.skip(f"the {route} emulation cannot speak from this host: {why}") + return emulation + + def _verdict(self, route: str, probe: str) -> str: + emulation = self._emulation_or_skip(route) + + result = _run( + _child_script( + emulation, + _BUILD_PROBE_PATH, + _EMULATE_310, + _ARM_THE_ROUTE, + _INJECT_DEAD_CWD, + _PATHLIB_ROUTE_PROBE if probe == "absolute" else _PATHLIB_ROUTE_PROBE_RELATIVE, + ), + cwd=Path(__file__).parent, + ) + lines = result.stdout.split() + assert "PROBE_NOT_ABS" not in lines, ( + f"the probe literal is not absolute under the {route} emulation — an " + f"absolute path is whatever that platform says it is:\n{result.stdout}" + ) + assert "ROUTE_ARMED" in lines, f"the route never armed ({route}/{probe}):\n{result.stdout}\n{result.stderr}" + + built = result.stdout.split("PROBE_PATH ", 1)[1].splitlines()[0] + if route == "nt": + assert ntpath.splitdrive(built)[0], ( + "the windows emulation built a drive-less literal. On 3.12 that " + "changes nothing (ntpath.isabs still accepted rooted paths); on " + "3.13 abspath falls through to os.getcwd and the probe dies " + f"building its own argument: {built!r}" + ) + else: + assert not ntpath.splitdrive(built)[0], f"a posix row produced a drive: {built!r}" + + armed = [line for line in lines if line.startswith("PATHLIB_")] + assert armed, f"the probe reported nothing ({route}/{probe}):\n{result.stdout}\n{result.stderr}" + return armed[0] + + @pytest.mark.parametrize(("route", "probe"), sorted(_GETCWD_TABLE)) + def test_the_getcwd_denial_answers_the_same_way_the_table_says(self, route, probe): + expected = _GETCWD_TABLE[(route, probe)] + + assert self._verdict(route, probe) == expected, ( + f"the {route} route answered differently for a {probe} path than the " + "table says. If this is the nt row, read ntpath.realpath again " + "before editing the table — the getcwd read ABOVE the isabs check " + "is what the row is derived from (ordering, not line number)." + ) + + def test_the_live_platform_agrees_with_its_own_row(self): + """The emulations are stand-ins; this is the host answering for itself.""" + route = "nt" if os.name == "nt" else "posix" + + result = _run( + _child_script(_BUILD_PROBE_PATH, _EMULATE_310, _ARM_THE_ROUTE, _INJECT_DEAD_CWD, _PATHLIB_ROUTE_PROBE), + cwd=Path(__file__).parent, + ) + lines = result.stdout.split() + assert "ROUTE_ARMED" in lines, result.stdout + live = [line for line in lines if line.startswith("PATHLIB_")][0] + + assert live == _GETCWD_TABLE[(route, "absolute")], ( + f"this host is {os.name!r} and answered {live} where its own table " + f"row says {_GETCWD_TABLE[(route, 'absolute')]}" + ) + + def test_the_relative_probe_is_inert_with_no_world_at_all(self): + """The control for the control: no denial, no arming.""" + result = _run( + _child_script(_BUILD_PROBE_PATH, _EMULATE_310, _ARM_THE_ROUTE, _PATHLIB_ROUTE_PROBE_RELATIVE), + cwd=Path(__file__).parent, + ) + + assert "PATHLIB_INERT" in result.stdout.split(), ( + "the relative probe raised with NO world installed — it is " + f"convicting for its own shape, not for the denial:\n{result.stdout}\n{result.stderr}" + ) + + def test_a_posix_route_with_a_foreign_dialect_class_is_the_windows_red(self): + """CI's windows red, reproduced from Linux — and the reason for the gate. + + Same posix emulation, same denial, same probe literal. The only change + is a concrete class that spells the path back in another dialect, which + is precisely what Windows contributes to this row and the one thing the + emulation never replaced. INERT becomes ARMED, and the row's expectation + was a posix fact all along. + + Kept as a test rather than a comment because it is also the negative + control for the skip above: if the gate ever lets an nt host run this + row again, this is the verdict it will get. + """ + emulation = self._emulation_or_skip("posix") + + result = _run( + _child_script( + emulation, + _BUILD_PROBE_PATH, + _A_CONCRETE_CLASS_THAT_SPELLS_ANOTHER_DIALECT, + _EMULATE_310, + _ARM_THE_ROUTE, + _INJECT_DEAD_CWD, + _PATHLIB_ROUTE_PROBE, + ), + cwd=Path(__file__).parent, + ) + + assert "DIALECT_INSTALLED" in result.stdout.split(), ( + f"the chimera never got built, so this measured nothing:\n{result.stdout}\n{result.stderr}" + ) + assert "PATHLIB_ARMED" in result.stdout.split(), ( + "a posix os.path with a foreign-dialect concrete class no longer " + "reads the cwd for an absolute literal — if this is genuinely fixed " + f"upstream, the windows skip above can go:\n{result.stdout}\n{result.stderr}" + ) + assert self._verdict("posix", "absolute") == "PATHLIB_INERT", ( + "the control moved too — then the difference above is not the dialect" + ) + + def test_a_stand_in_that_subclasses_the_class_it_replaces_is_downgraded(self): + """Why the dialect stand-in patches a dunder instead of adding a class. + + The obvious way to make Path spell paths differently is to subclass it + and install the subclass. On 3.12 that quietly does not work: Path.__new__ + asks `if cls is Path`, `Path` is a global lookup in pathlib, and the + installation just rebound it — so the check matches the subclass, __new__ + returns a plain PosixPath, and __init__ never runs on it. What comes back + is a half-built object of the WRONG class that dies on first str(). + + Reported as measured rather than asserted as universal: a version where + the identity check is gone would keep the subclass, and that is a legal + answer here. What must never happen is the third state — a subclass that + survives as a half-built object nobody notices. + """ + result = _run( + _child_script( + _BUILD_PROBE_PATH, + """ + import pathlib + + class _Subclassed(pathlib.Path): + pass + + pathlib.Path = _Subclassed + _made = pathlib.Path(_ARM_ABS) + _kept = type(_made) is _Subclassed + print("SUBCLASS_KEPT" if _kept else "SUBCLASS_DOWNGRADED", type(_made).__name__) + print("INITIALISED" if hasattr(_made, "_raw_paths") else "UNINITIALISED") + """, + ), + cwd=Path(__file__).parent, + ) + + lines = result.stdout.split() + assert ("SUBCLASS_KEPT" in lines) != ("SUBCLASS_DOWNGRADED" in lines), ( + f"the probe reported neither verdict:\n{result.stdout}\n{result.stderr}" + ) + if "SUBCLASS_DOWNGRADED" in lines: + assert "UNINITIALISED" in lines, ( + "the interpreter swapped the class but ran __init__ anyway — then " + "the trap this comment describes is not the one that happens here" + ) + else: + assert "INITIALISED" in lines, ( + "a subclass survived installation as a HALF-BUILT object: it will " + f"die at the first str(), a long way from here:\n{result.stdout}" + ) + + def test_no_row_of_the_table_runs_bare(self): + """@memory's rule, pinned where it can rot: emulate BOTH or neither. + + Round 8's windows red was a posix row with no prelude — which reads as + "posix" only while the host is posix. This host cannot tell a bare row + from an emulated one behaviourally (they are the same here), so the + claim is pinned on the table itself, which is where the mistake was. + """ + for route, (prelude, _requires) in _PLATFORM_EMULATIONS.items(): + assert prelude.strip(), ( + f"the {route!r} row installs nothing, so it measures whatever " + "host runs it under a label that says otherwise" + ) + assert set(_PLATFORM_EMULATIONS) == {row for row, _ in _GETCWD_TABLE}, ( + "a table row has no platform emulation, or an emulation has no rows" + ) + + @pytest.mark.parametrize("route", sorted(_PLATFORM_EMULATIONS)) + def test_an_emulation_stacked_on_its_own_platform_is_one_layer(self, route): + """@seedgo's round-8 find, relayed by @devpulse, checked here. + + They stacked an nt emulation on an nt-shaped world and recursed to the + stack limit: host == emulated is one layer, and an emulation whose + replacement calls the name it replaced eats itself. On the Windows + runner my nt emulation IS applied to a host where ntpath is already + os.path, so that is not a hypothetical for this file — it is just + invisible from Linux unless the emulation is applied twice. + """ + emulation = self._emulation_or_skip(route) + result = _run( + _child_script( + emulation, + emulation, + _BUILD_PROBE_PATH, + _EMULATE_310, + _ARM_THE_ROUTE, + _PATHLIB_ROUTE_PROBE, + ), + cwd=Path(__file__).parent, + ) + + assert "RecursionError" not in result.stderr, ( + f"the {route} emulation calls the name it replaced — stacking it on " + f"its own platform recurses:\n{result.stderr[-400:]}" + ) + assert "ROUTE_ARMED" in result.stdout.split(), ( + f"the {route} emulation stopped working when applied twice:\n{result.stdout}\n{result.stderr}" + ) + + @pytest.mark.parametrize("route", sorted(_PLATFORM_EMULATIONS)) + def test_each_platform_emulation_is_inert_without_the_denial(self, route): + """And the same for the platform stand-ins themselves. + + An emulation that raises on its own would make every row above + vacuously ARMED — @canary's round-4 species, applied to a platform + instead of a world. + """ + result = _run( + _child_script( + self._emulation_or_skip(route), + _BUILD_PROBE_PATH, + _EMULATE_310, + _ARM_THE_ROUTE, + _PATHLIB_ROUTE_PROBE, + ), + cwd=Path(__file__).parent, + ) + + assert "PATHLIB_INERT" in result.stdout.split(), ( + f"the {route} emulation arms the probe with no denial installed — " + f"every {route} row above is measuring the stand-in:\n{result.stdout}\n{result.stderr}" + ) + + +# ============================================================================= +# The defect itself — portable form (runs on every OS) +# ============================================================================= + + +@pytest.mark.parametrize("world", sorted(PORTABLE_WORLDS)) +@pytest.mark.parametrize("class_name", TEMPLATE_CLASSES) +def test_newborn_import_survives_a_cwd_that_cannot_be_read(class_name, world, tmp_path): + """Import the newborn's handlers where reading the cwd raises. + + `-c` gives the top frame the pseudo-filename ``, and the defective + guard resolved it before skipping it — so the import died on a call the + fixed guard never makes. MEASURED red against the pre-fix form on Linux. + """ + root = _plant_newborn(tmp_path / "tree", class_name) + (tmp_path / "stand_here").mkdir() + + result = _in_dead_cwd( + root, + PORTABLE_WORLDS[world], + """ + import aipass.newborn.apps.handlers + print("IMPORTED") + """, + cwd=tmp_path / "stand_here", + ) + + lines = _require_live_world(result, world) + assert result.returncode == 0, ( + f"a newborn of class '{class_name}' cannot be imported in the '{world}' world:\n{result.stderr}" + ) + assert lines[-1] == "IMPORTED" + + +@pytest.mark.parametrize("world", sorted(PORTABLE_WORLDS)) +@pytest.mark.parametrize("class_name", TEMPLATE_CLASSES) +def test_resolve_is_never_reached_for_a_pseudo_filename(class_name, world, tmp_path): + """Ordering pin: pseudo-files are skipped before anything touches the disk. + + Behaviourally identical to the pin above for today's code, but it fails on + the ORDERING rather than on the whole import — so a future rewrite that + reintroduces an early resolve() is named precisely. + """ + root = _plant_newborn(tmp_path / "tree", class_name) + (tmp_path / "stand_here").mkdir() + + result = _in_dead_cwd( + root, + PORTABLE_WORLDS[world], + """ + from aipass.newborn.apps.handlers import _find_real_caller + + caller, line = _find_real_caller() + print("NO_CRASH") + """, + cwd=tmp_path / "stand_here", + ) + + lines = _require_live_world(result, world) + assert result.returncode == 0, f"_find_real_caller still needs a cwd:\n{result.stderr}" + assert lines[-1] == "NO_CRASH" + + +# ============================================================================= +# The defect itself — real-world form (POSIX only, by ruling) +# ============================================================================= + + +@pytest.mark.skipif(sys.platform == "win32", reason=WINDOWS_RECIPE_SKIP) +@pytest.mark.parametrize("class_name", TEMPLATE_CLASSES) +def test_newborn_import_survives_a_deleted_working_directory(class_name, tmp_path): + """The same claim against a genuinely deleted cwd — no faking at all. + + This is the pin the injection stands in for. Keeping it means the stand-in + is never the only evidence on the platforms where the real world is + reachable. + """ + root = _plant_newborn(tmp_path / "tree", class_name) + (tmp_path / "stand_here").mkdir() + + result = _in_dead_cwd( + root, + _DELETE_CWD, + """ + import aipass.newborn.apps.handlers + print("IMPORTED") + """, + cwd=tmp_path / "stand_here", + ) + + lines = _require_live_world(result, "getcwd-denied") # _DELETE_CWD is a cwd world + assert result.returncode == 0, ( + f"a newborn of class '{class_name}' cannot be imported from a deleted cwd:\n{result.stderr}" + ) + assert lines[-1] == "IMPORTED" + + +class TestBothConstructionsAgree: + """@memory's licensing condition, made a test rather than a promise. + + The injection is a legitimate stand-in only for as long as it produces the + IDENTICAL answer from the IDENTICAL call site as the real deleted-directory + world. Both run on POSIX, so that is checkable here every time the suite + runs — and the day it stops being true, this goes red instead of the + stand-in quietly drifting away from the world it claims to model. + """ + + CALL_SITE = """ + from pathlib import Path + + try: + Path("").resolve() + except OSError: + print("RAISED") + else: + print("RESOLVED") + """ + + @pytest.mark.skipif(sys.platform == "win32", reason=WINDOWS_RECIPE_SKIP) + def test_the_two_worlds_answer_the_call_site_identically(self, tmp_path): + (tmp_path / "stand_here").mkdir() + + injected = _in_dead_cwd(tmp_path, _INJECT_DEAD_CWD, self.CALL_SITE, tmp_path / "stand_here") + deleted = _in_dead_cwd(tmp_path, _DELETE_CWD, self.CALL_SITE, tmp_path / "stand_here") + + assert injected.returncode == deleted.returncode == 0 + assert injected.stdout.split() == deleted.stdout.split() == ["CONTROL_LIVE", "RAISED"], ( + "the injection no longer models the deleted-directory world:\n" + f" injected: {injected.stdout.split()}\n deleted: {deleted.stdout.split()}" + ) + + def test_the_control_probe_can_report_a_world_that_did_not_take(self): + """The control's own negative control — it must be able to say NO. + + A control that always reports CONTROL_LIVE is worse than no control: it + turns every portable pin into a vacuous green on exactly the platform + where the injection may not reach the call site. Run the probe with no + world applied at all; a healthy cwd must make it say so. + """ + result = _run(_NO_WORLD_AT_ALL, cwd=Path(__file__).parent) + + assert result.returncode == 0, result.stderr + assert result.stdout.split() == ["CONTROL_DEAD"], ( + "the control probe reports a broken world on a healthy machine — it " + f"cannot distinguish anything: {result.stdout!r}" + ) + + def test_the_windows_skip_states_the_ruling_not_just_the_symptom(self): + """The skip reason is the evidence a future reader gets — pin it. + + Readable from Linux, so both answers are observable without a Windows + box: what is pinned is what the skipped tests SAY, not that they ran. + """ + marks = [m for m in test_newborn_import_survives_a_deleted_working_directory.pytestmark if m.name == "skipif"] + assert len(marks) == 1, "the deleted-cwd recipe lost its platform skip" + + reason = marks[0].kwargs["reason"] + assert "WinError 32" in reason + assert "RECIPE" in reason and "STATE" in reason, "the skip must say WHY Windows is excused, not just that it is" + assert marks[0].args[0] is (sys.platform == "win32") + + +# ============================================================================= +# The fence the fix must not have weakened +# ============================================================================= + + +@pytest.mark.parametrize("class_name", TEMPLATE_CLASSES) +def test_newborn_still_refuses_an_outside_caller(class_name, tmp_path): + """A file outside the newborn's tree importing its handlers is still blocked.""" + root = _plant_newborn(tmp_path / "tree", class_name) + + outsider = tmp_path / "outsider" + outsider.mkdir() + caller = outsider / "trespass.py" + caller.write_text( + textwrap.dedent( + f""" + import sys + + sys.path.insert(0, {str(root)!r}) + + try: + import aipass.newborn.apps.handlers + except ImportError as exc: + print("BLOCKED" if "ACCESS DENIED" in str(exc) else "OTHER") + else: + print("ALLOWED") + """ + ).strip(), + encoding="utf-8", + ) + + result = subprocess.run( + [sys.executable, str(caller)], + capture_output=True, + text=True, + cwd=str(tmp_path), + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "BLOCKED" + + +@pytest.mark.parametrize("class_name", TEMPLATE_CLASSES) +def test_newborn_admits_its_own_code(class_name, tmp_path): + """A file inside the newborn's own tree imports its handlers freely.""" + root = _plant_newborn(tmp_path / "tree", class_name) + + insider = root / "aipass" / "newborn" / "apps" / "inside.py" + insider.write_text( + textwrap.dedent( + f""" + import sys + + sys.path.insert(0, {str(root)!r}) + + import aipass.newborn.apps.handlers + print("ADMITTED") + """ + ).strip(), + encoding="utf-8", + ) + + result = subprocess.run( + [sys.executable, str(insider)], + capture_output=True, + text=True, + cwd=str(tmp_path), + ) + + assert result.returncode == 0, f"the guard locked a newborn out of its own handlers:\n{result.stderr}" + assert result.stdout.strip() == "ADMITTED" + + +# ============================================================================= +# The wider species: import-time resolve() anywhere in the template +# ============================================================================= + + +def _unguarded_module_level_resolves(source: str) -> list: + """Line numbers of resolve() calls that run at import and are not guarded. + + Module level only, and outside any try — see the class docstring for why + function-level resolves are deliberately not flagged. + """ + tree = ast.parse(source) + + guarded = { + node.lineno + for block in ast.walk(tree) + if isinstance(block, ast.Try) + for node in ast.walk(block) + if hasattr(node, "lineno") + } + + # Walk what RUNS at import, which is not the same as what is WRITTEN at + # module level — @memory's correction, 2026-08-31, after the last crash + # standing in their tree was a resolve() in a find_repo_root DEFAULT + # ARGUMENT: written inside a def, evaluated at import anyway. So a + # function's body is pruned (that resolve is a deliberate raise, see the + # class docstring) but its defaults and decorators are NOT. + # + # STATED LIMIT: a module-level call into a locally defined function also + # runs at import and is not followed here. That needs a call graph, and + # naming the gap beats a pin that reads like it covers more than it does. + found = [] + pending = list(tree.body) + while pending: + node = pending.pop() + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + pending.extend(getattr(node, "decorator_list", [])) + args = getattr(node, "args", None) + if args is not None: + pending.extend(args.defaults) + pending.extend(d for d in args.kw_defaults if d is not None) + continue + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "resolve" + and node.lineno not in guarded + ): + found.append(node.lineno) + pending.extend(ast.iter_child_nodes(node)) + + return sorted(found) + + +class TestImportTimeRootDerivationSurvivesADeadCwd: + """A newborn must not inherit a cwd read that runs at import. + + @memory raised this as the wider species after the guard fix (2026-08-31) + and left the template ruling to me: `Path(__file__).resolve()` is a cwd read + on Windows anywhere it appears, because ntpath.realpath computes os.getcwd() + unconditionally rather than only for relative paths. + + MY RULING, and the sweep that backs it: severity splits on WHEN it runs. + + * At MODULE level it is unrecoverable and it spreads — the template's + json_handler derives its branch root that way, and nearly every module in + a newborn imports json_handler, so one dead-cwd process loses the whole + branch with a traceback from inside the stdlib. These are guarded, falling + back to the unresolved absolute path (`__file__` has been absolute since + 3.9, so only symlink normalisation is lost, and only in the world where + the alternative is a dead import). + * Inside a FUNCTION it stays exactly as written. It fails where a caller can + see it, and a root that is wrong-but-plausible is worse than a raise — + "fail to errors, never fall back silently" is the branch's own rule. The + pin below is scoped to module level for that reason, not by oversight. + """ + + def test_the_json_handler_root_derivation_survives_realpath_denial(self, tmp_path): + """Run the template's own module-level derivation in the denied world.""" + source = (get_template_dir("specialist") / "apps" / "handlers" / "json" / "json_handler.py").read_text( + encoding="utf-8" + ) + derivation = source[source.index("_BRANCH_ROOT = ") : source.index("_JSON_DIR")] + + probe = tmp_path / "derive.py" + probe.write_text( + textwrap.dedent( + """ + import errno, os, os.path + from pathlib import Path + + def _no_realpath(*args, **kwargs): + raise FileNotFoundError(errno.ENOENT, "No such file or directory") + + os.path.realpath = _no_realpath + + """ + ).lstrip() + + derivation + + '\nprint("DERIVED", "ABSOLUTE" if _BRANCH_ROOT.is_absolute() else "RELATIVE")\n', + encoding="utf-8", + ) + + result = subprocess.run([sys.executable, str(probe)], capture_output=True, text=True, cwd=str(tmp_path)) + + assert result.returncode == 0, f"the template's import-time root derivation needs a cwd:\n{result.stderr}" + assert result.stdout.split()[0] == "DERIVED" + assert result.stdout.split()[1] == "ABSOLUTE", ( + "the derivation produced a relative root — __file__ has been absolute " + "since 3.9 and dropping resolve() relies on exactly that" + ) + + def test_no_unguarded_module_level_resolve_anywhere_in_the_template(self): + """The species, not the site — so a newborn cannot re-inherit the idiom. + + Scoped to module level: a resolve() inside a function is a deliberate + raise, and flagging it here would push future authors toward silently + wrong roots. + """ + offenders = [] + for path in sorted(get_template_dir("specialist").rglob("*.py")): + # Render first: the template carries {{BRANCH}} placeholders that + # are not valid Python. Skipping unparseable files instead would + # exempt exactly the files most likely to carry the idiom. + offenders += [ + f"{path.name}:{line}" + for line in _unguarded_module_level_resolves(_render(path.read_text(encoding="utf-8"))) + ] + + assert offenders == [], ( + "unguarded module-level resolve() in the template — this runs at " + "import on every newborn, and ntpath.realpath reads the cwd even for " + f"an absolute path: {offenders}" + ) + + @pytest.mark.parametrize( + "label,source,expected", + [ + ( + "module-level unguarded — the defect", + "from pathlib import Path\nROOT = Path(__file__).resolve().parents[3]\n", + [2], + ), + ( + "module-level guarded — the cure", + "from pathlib import Path\ntry:\n ROOT = Path(__file__).resolve().parents[3]\n" + "except OSError:\n ROOT = Path(__file__).parents[3]\n", + [], + ), + ( + "inside a function — deliberately out of scope", + "from pathlib import Path\ndef root():\n return Path(__file__).resolve().parents[3]\n", + [], + ), + ( + "default argument — written in a def, RUN at import", + "from pathlib import Path\ndef root(base=Path(__file__).resolve()):\n return base\n", + [2], + ), + ( + "keyword-only default — same, one syntax over", + "from pathlib import Path\ndef root(*, base=Path(__file__).resolve()):\n return base\n", + [2], + ), + ( + "decorator — also evaluated at import", + "from pathlib import Path\n@register(Path(__file__).resolve())\ndef root():\n pass\n", + [2], + ), + ], + ) + def test_the_detector_can_tell_the_three_cases_apart(self, label, source, expected): + """The detector's own control — it must be able to say yes AND no. + + A structural pin that quietly stopped detecting would go green forever + and read as coverage. Same lesson as the control probe above: a check + that can only produce one answer has not been tested, it was assumed. + """ + assert _unguarded_module_level_resolves(source) == expected, label + + +# ============================================================================= +# Optional peer imports must survive a peer that is BROKEN, not just absent +# ============================================================================= + + +class TestOptionalPeerImportsAreWideEnough: + """A peer branch being broken is allowed; dying of it is not. + + Raised by @prax 2026-08-31 from their own watcher: they declared the + @trigger import optional and caught only ImportError, while trigger's + handler guard raises FileNotFoundError. An optional dependency's fallback + has to be at least as wide as the failures its import can produce — and a + peer's handlers package does real filesystem work at import time, so a dead + or unreadable cwd surfaces as OSError, not ImportError. + + The width is deliberately OSError and not Exception: a peer that is + unavailable or broken at the filesystem level is weather, but a peer with a + genuine programming error should still take spawn down loudly rather than + be swallowed into an empty dict. + """ + + @staticmethod + def _deny(module_prefix, exc): + """A finder that raises `exc` for the named module and its children.""" + + class _Denier: + @staticmethod + def find_spec(name, path=None, target=None): + if name == module_prefix or name.startswith(module_prefix + "."): + raise exc + return None + + return _Denier + + @pytest.mark.parametrize( + "exc", + [ + FileNotFoundError(2, "No such file or directory"), + PermissionError(13, "Permission denied"), + ImportError("@memory is not installed"), + ], + ids=["dead-cwd", "unreadable", "absent"], + ) + def test_meta_tabs_fall_back_when_memory_is_broken_or_absent(self, exc, monkeypatch): + from aipass.spawn.apps.modules import core + + denier = self._deny("aipass.memory", exc) + monkeypatch.setattr(sys, "meta_path", [denier] + sys.meta_path) + for name in [m for m in sys.modules if m.startswith("aipass.memory")]: + monkeypatch.delitem(sys.modules, name, raising=False) + + assert core._load_meta_tabs() == {}, ( + f"a peer raising {type(exc).__name__} took spawn's meta-tab lookup down instead of falling back" + ) + + def test_the_denier_actually_denies(self, monkeypatch): + """Negative control: an inert finder would make the pins above vacuous.""" + denier = self._deny("aipass.memory", FileNotFoundError(2, "denied")) + monkeypatch.setattr(sys, "meta_path", [denier] + sys.meta_path) + for name in [m for m in sys.modules if m.startswith("aipass.memory")]: + monkeypatch.delitem(sys.modules, name, raising=False) + + with pytest.raises(FileNotFoundError): + importlib.import_module("aipass.memory.apps.handlers.tracking.tab_renderer") + + def test_a_real_programming_error_is_still_fatal(self, monkeypatch): + """The width is OSError, not Exception — a broken peer must stay loud.""" + denier = self._deny("aipass.memory", ValueError("peer has a bug")) + monkeypatch.setattr(sys, "meta_path", [denier] + sys.meta_path) + for name in [m for m in sys.modules if m.startswith("aipass.memory")]: + monkeypatch.delitem(sys.modules, name, raising=False) + + from aipass.spawn.apps.modules import core + + with pytest.raises(ValueError): + core._load_meta_tabs() + + +# ============================================================================= +# The deleted stack walk, pinned structurally +# ============================================================================= + + +def _inspect_stack_calls(source: str) -> list: + """Line numbers of every ``inspect.stack()`` CALL in ``source``. + + An AST matcher, never a string search. The guard's own docstring names + ``inspect.stack()`` while explaining the defect, so a spelling ban would + convict the explanation and force the cure to be undocumented — the pin + would be arguing against its own comment. + + Handles the three ways the call can be spelled, because a ban that only + knows one is an invitation to the other two: + + * ``inspect.stack()`` — attribute on the module name + * ``import inspect as i`` then ``i.stack()`` — module bound to an alias + * ``from inspect import stack`` then ``stack()`` — the function bound direct + + Deliberately NOT convicted: ``inspect.currentframe()`` (that is + ``sys._getframe`` under another name and touches no filesystem), any + ``.stack`` attribute on something that is not the inspect module, and a bare + reference that is never called. + """ + tree = ast.parse(source) + + module_aliases = {"inspect"} + direct_names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "inspect": + module_aliases.add(alias.asname or alias.name) + elif isinstance(node, ast.ImportFrom) and node.module == "inspect": + for alias in node.names: + if alias.name == "stack": + direct_names.add(alias.asname or alias.name) + + found = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "stack" + and isinstance(func.value, ast.Name) + and func.value.id in module_aliases + ): + found.append(node.lineno) + elif isinstance(func, ast.Name) and func.id in direct_names: + found.append(node.lineno) + + return sorted(found) + + +# Every .py under these roots, excluding the retired template archive. +_BANNED_ROOTS = (Path(__file__).resolve().parents[1] / "apps", get_template_dir("specialist")) + +# Measured 2026-08-31: 48 files across apps/ and templates/citizen/. The floor +# exists so a walk that silently stops finding files cannot read as "clean" — +# a blinded sweep and a cured tree produce the same empty list otherwise. +_MIN_FILES_SWEPT = 40 + + +class TestTheStackWalkCannotComeBack: + """The deleted second walk has no behavioural instrument — so pin the source. + + MEASURED FLEET-WIDE and relayed by @devpulse 2026-08-31: the + ``caller_file is None`` branch in ``_guard_branch_access`` is unreachable + from any import-shaped test, because ``apps/__init__.py`` always supplies a + real-file frame. Nine branches reproduced it identically — restoring the + walk leaves the whole suite green. This branch is the copy every newborn + inherits, so an unpinned regression here would ship. + + A structural pin is the honest instrument for a defect whose only symptom + is a call that must not exist. + """ + + def test_the_guard_has_no_stack_walk(self): + source = (Path(__file__).resolve().parents[1] / "apps" / "handlers" / "__init__.py").read_text(encoding="utf-8") + + assert _inspect_stack_calls(source) == [], ( + "inspect.stack() is back in the handler guard. It reads the cwd on " + "Windows before any of the guard's own code runs — walk frames with " + "sys._getframe instead" + ) + + def test_no_stack_walk_anywhere_in_apps_or_the_template(self): + """Tree-wide, because zero legitimate callers remain — CHECKED, not assumed. + + @devpulse's warning was earned elsewhere: @commons applied this ban + without looking first and found a LIVE stack walk the dispatch had not + named. So this was measured before widening — spawn's json_handler is a + pure shim over aipass.aipass.shared.json_handler with no local walk, and + that shared implementation is itself already on sys._getframe(2). + """ + offenders = [] + swept = 0 + + for root in _BANNED_ROOTS: + for path in sorted(root.rglob("*.py")): + if ".archive" in path.parts: + continue + swept += 1 + offenders += [ + f"{path.name}:{line}" for line in _inspect_stack_calls(_render(path.read_text(encoding="utf-8"))) + ] + + assert swept >= _MIN_FILES_SWEPT, ( + f"the sweep only reached {swept} files (expected at least " + f"{_MIN_FILES_SWEPT}) — an empty result from a blinded walk is not " + "evidence of a clean tree" + ) + assert offenders == [], f"inspect.stack() at import-capable sites: {offenders}" + + +class TestTheStackMatcherIsTheRealOne: + """Controls run through the SHIPPED matcher, never a copy of it. + + A control that exercises a second implementation proves that copy correct + and says nothing about the pin above. + """ + + def test_a_planted_call_convicts_at_the_right_line(self): + source = "import inspect\n\n\ndef f():\n return inspect.stack()\n" + + assert _inspect_stack_calls(source) == [5] + + def test_the_guards_own_docstring_is_not_a_violation(self): + """The cure explains itself by naming the defect — that must stay legal. + + Takes the guard's REAL docstring and puts it in a stub, rather than + asserting the whole live file is clean. The first version did the + latter, and the mutant run exposed it: restoring the walk made this + "control" red too, which means it was a second copy of the ban wearing a + control's name. A control has to fail for the reason it exists — here, + only if prose starts convicting. + """ + guard = Path(__file__).resolve().parents[1] / "apps" / "handlers" / "__init__.py" + tree = ast.parse(guard.read_text(encoding="utf-8")) + + docstrings = [ + ast.get_docstring(node) + for node in ast.walk(tree) + if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef)) + ] + prose = "\n".join(d for d in docstrings if d) + + assert "inspect.stack()" in prose, ( + "the guard no longer explains what it replaced — this control is measuring nothing" + ) + assert _inspect_stack_calls(f'def stub():\n """{prose}"""\n') == [] + + @pytest.mark.parametrize( + "label,source,expected", + [ + ("numpy.stack", "import numpy\nx = numpy.stack([1])\n", []), + ("traceback.stack", "import traceback\nx = traceback.stack()\n", []), + ("self.stack", "class C:\n def f(self):\n return self.stack()\n", []), + ("bare reference, never called", "import inspect\nf = inspect.stack\n", []), + ("inspect.currentframe is legal", "import inspect\nx = inspect.currentframe()\n", []), + ("module alias", "import inspect as i\nx = i.stack()\n", [2]), + ("direct import", "from inspect import stack\nx = stack()\n", [2]), + ("direct import aliased", "from inspect import stack as s\nx = s()\n", [2]), + ("a different from-import", "from inspect import currentframe\nx = currentframe()\n", []), + ], + ) + def test_the_matcher_tells_the_cases_apart(self, label, source, expected): + assert _inspect_stack_calls(source) == expected, label + + +class TestNewbornsAreBornPinned: + """The template ships the AST pin, and it works in the position it lands in. + + TEMPLATE OWNER'S DECISION, stated rather than left implicit: YES, the pin + ships in ``templates/citizen/tests/test_scaffold.py``. A newborn inherits + the cured guard, but without a pin a future author regrows the walk and + every suite stays green — which is exactly the fleet-wide condition + @devpulse measured in nine branches. test_scaffold.py is the right home + because it is self-contained: unlike the fixture smoke test beside it, the + pin needs no conftest, so it survives a branch replacing the template + scaffolding with its own suite. + + STATED LIMIT: ``spawn update`` never overwrites .py files, so this reaches + FUTURE citizens only. The 18 existing branches need their own owners to add + it — which is what @devpulse's relay is doing. Shipping it here is not a + claim that the fleet is covered. + + These tests render the template into a throwaway newborn and run its own + scaffold pin against it, green AND red, because a shipped test nobody has + executed in its landing position is a guess. + """ + + @staticmethod + def _mint(root: Path, guard_source: str) -> Path: + branch = root / "newborn" + (branch / "apps" / "handlers").mkdir(parents=True) + (branch / "tests").mkdir() + (branch / "apps" / "handlers" / "__init__.py").write_text(guard_source, encoding="utf-8") + + scaffold = get_template_dir("specialist") / "tests" / "test_scaffold.py" + (branch / "tests" / "test_scaffold.py").write_text( + _render(scaffold.read_text(encoding="utf-8")), encoding="utf-8" + ) + return branch + + @staticmethod + def _run_scaffold(branch: Path): + return subprocess.run( + [ + sys.executable, + "-m", + "pytest", + str(branch / "tests" / "test_scaffold.py"), + "-p", + "no:randomly", + "-q", + "--no-header", + "-k", + "stack", + ], + capture_output=True, + text=True, + cwd=str(branch), + ) + + def test_a_newborn_with_the_cured_guard_passes_its_own_pin(self, tmp_path): + cured = _render(_template_guard("specialist").read_text(encoding="utf-8")) + branch = self._mint(tmp_path, cured) + + result = self._run_scaffold(branch) + + assert result.returncode == 0, f"a newborn fails the pin it is born with:\n{result.stdout}\n{result.stderr}" + + def test_a_newborn_that_regrows_the_walk_fails_its_own_pin(self, tmp_path): + """The pin the newborn ships has to be able to convict, not just pass.""" + regressed = _render(_template_guard("specialist").read_text(encoding="utf-8")).replace( + " frame = sys._getframe(1)", + " import inspect\n\n stack = inspect.stack()\n frame = sys._getframe(1)", + ) + assert "inspect.stack()" in regressed + branch = self._mint(tmp_path, regressed) + + result = self._run_scaffold(branch) + + assert result.returncode != 0, ( + "a newborn that regrew the stack walk still passed its own pin — the " + f"shipped pin cannot convict:\n{result.stdout}" + ) + assert "handler guard" in result.stdout + + +class TestTheCallerIsNoneBranchHasABehaviouralInstrumentAfterAll: + """A behavioural pin for the branch @devpulse's sweep called unreachable. + + Their fleet measurement (nine branches, 2026-08-31) is correct as stated: + the ``caller_file is None`` branch is unreachable from any IMPORT-shaped + test, because ``apps/__init__.py`` eagerly imports handlers and so always + supplies a real-file frame. That is why restoring the walk leaves whole + suites green. + + It is reachable from a DIRECT-CALL-shaped test. Called from a ``python -c`` + child, the only frames are ```` and importlib — both skipped — so + ``_find_real_caller`` returns ``(None, None)`` and the branch runs. Deny + ``os.path.realpath`` in that child and the restored walk dies where the + cured guard returns cleanly. + + MEASURED both ways before writing this, against spawn's live guard: + cured -> GUARD_OK + walk back -> GUARD_RAISED FileNotFoundError + + So the AST pin above is not the only thing standing between the fleet and a + silent regrowth — it is just the only one that needs no subprocess. Relayed + to @devpulse, because eight other branches were told no instrument exists. + """ + + PROBE = """ + import errno, os, os.path + from pathlib import Path + + from aipass.spawn.apps.handlers import _find_real_caller, _guard_branch_access + + # Arming probe: the branch under test only runs when there is no caller + # frame outside the guard. If a real-file frame ever appears here, this + # test is exercising a different path and must say so rather than pass. + caller, _line = _find_real_caller() + print("CALLER_IS_NONE" if caller is None else f"CALLER_IS_{caller}") + + def _no_realpath(*args, **kwargs): + raise FileNotFoundError(errno.ENOENT, "No such file or directory") + + os.path.realpath = _no_realpath + + import pathlib as _pathlib + + if hasattr(_pathlib, "_NormalAccessor"): + _pathlib._NormalAccessor.realpath = staticmethod(_no_realpath) + if hasattr(_pathlib, "_normal_accessor"): + _pathlib._normal_accessor.realpath = _no_realpath + + # Second arming probe, split by ROUTE. The guard can reach realpath two + # ways and they are denied by different patches, so one combined + # DENIAL_LIVE would hide a half-armed world: + # + # direct — inspect.stack() -> getsourcefile -> getmodule -> + # os.path.realpath(f). This is the route a REGROWN walk + # takes, and it is what this pin exists to catch. + # pathlib — _find_real_caller's own Path(...).resolve() calls, which + # on <=3.10 go through the captured accessor and are NOT + # denied by the rebinding above. + # + # Both paths are ABSOLUTE. @devpulse's trap (c): a relative probe path + # can raise because the path is relative and the cwd is unreadable, + # which convicts for the path's shape rather than for the denial. + _abs = os.path.join(os.sep, "definitely", "not", "here") + + try: + os.path.realpath(_abs) + except OSError: + print("DENIAL_LIVE_DIRECT") + else: + print("DENIAL_DEAD_DIRECT") + + try: + Path(_abs).resolve() + except OSError: + print("DENIAL_LIVE_PATHLIB") + else: + print("DENIAL_DEAD_PATHLIB") + + _guard_branch_access() + print("GUARD_RETURNED") + """ + + def test_the_guard_returns_from_that_branch_without_touching_the_filesystem(self): + result = _run(self.PROBE, cwd=Path(__file__).resolve().parents[4]) + + lines = result.stdout.split() + assert lines and lines[0] == "CALLER_IS_NONE", ( + "the probe did not reach the caller-is-None branch — it is measuring " + f"a different path: {result.stdout!r} {result.stderr[-400:]}" + ) + for route in ("DIRECT", "PATHLIB"): + assert f"DENIAL_DEAD_{route}" not in lines, ( + f"the {route.lower()} realpath route was inert in the child, so this run " + f"claims nothing. On <=3.10 the pathlib route needs the captured " + f"accessor patched as well as os.path.realpath: {result.stdout!r}" + ) + assert f"DENIAL_LIVE_{route}" in lines, ( + f"the child never armed the {route.lower()} route: {result.stdout!r}" + ) + + assert result.returncode == 0, ( + "the caller-is-None branch needs the filesystem — a restored " + f"inspect.stack() walk is the usual cause:\n{result.stderr}" + ) + assert lines[-1] == "GUARD_RETURNED" diff --git a/src/aipass/trigger/.seedgo/bypass.json b/src/aipass/trigger/.seedgo/bypass.json index f0c74e2ed..553484b20 100644 --- a/src/aipass/trigger/.seedgo/bypass.json +++ b/src/aipass/trigger/.seedgo/bypass.json @@ -2,10 +2,11 @@ "metadata": { "version": "2.1.0", "created": "2026-03-07T22:43:25.317608", - "updated": "2026-08-13", + "updated": "2026-08-31", "description": "Standards bypass configuration for @trigger — rebuilt from ground truth (FPLAN-0382 wave 1), re-measured after FPLAN-0384 scoping", "true_up": "Every rule below was re-derived by running the seedgo checkers over this branch with bypass_rules=[] and matching one rule to one surviving violation. 84 rules became 25: the rest were dead (file deleted, violation fixed, or target never audited). Re-derive the same way before editing — do not hand-adjust line numbers.", - "liveness_2026_08_09": "Leave-one-out re-measurement after FPLAN-0384 landed: 24 of 25 rules provably suppress a real violation (audit lane 24 hits with rules=[], 0 with the full set). The dead one — error_handling on events/error_detected.py — was removed. NOTE for whoever measures next: audit-lane unused_function records carry no line/name field (function names live inside the `issues` strings), so attributing a functions-scoped rule by record fields reports a false DEAD. A/B the rule instead." + "liveness_2026_08_09": "Leave-one-out re-measurement after FPLAN-0384 landed: 24 of 25 rules provably suppress a real violation (audit lane 24 hits with rules=[], 0 with the full set). The dead one — error_handling on events/error_detected.py — was removed. NOTE for whoever measures next: audit-lane unused_function records carry no line/name field (function names live inside the `issues` strings), so attributing a functions-scoped rule by record fields reports a false DEAD. A/B the rule instead.", + "line_scope_2026_08_31": "Round 5. FOUR line-scoped rules had gone stale: the round-4 dead-cwd sweep shifted every line below its edits, and a line-scoped waiver whose number moves stops suppressing SILENTLY — the violation resurfaces and reads as new breakage. That is what the round-5 CI red actually was. Re-derived by A/B (all four removed, checkers re-run, each rule matched to one surviving violation): encapsulation/log_watcher_service 26 (unchanged), help_text/log_watcher_service 17 (unchanged), encapsulation/runaway_handler 344 -> 336. The fourth, debug_print on log_watcher_service lines [67,78,86,105,108], is DELETED rather than re-derived: those five prints are prax logger calls now, so the rule suppresses nothing. Cure beat waiver. STANDING HAZARD: a line-scoped waiver is a pointer into a file that edits move. Re-derive by A/B after any edit above a waived line — never hand-adjust." }, "bypass": [ { @@ -104,43 +105,12 @@ "pattern": "handler importing from parent module", "reason": "L23 imports apps/modules/core because setup_handlers() calls trigger.on() for every handler. FILE-WIDE: architecture_check passes no line." }, - { - "file": "apps/handlers/events/runaway_handler.py", - "standard": "encapsulation", - "lines": [ - 344 - ], - "pattern": "from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch", - "reason": "LINE-SCOPED AND HONOURED: encapsulation_check is the one checker in the pack that passes a line to is_bypassed(). ai_mail publishes no modules/ entry point for waking a branch, so dispatch.wake is the only door. Line number drifts if anything above L344 changes — re-derive rather than nudge it." - }, { "file": "apps/log_watcher_service.py", "standard": "cli", "pattern": "bare print() + no --help", "reason": "Systemd unit, not a CLI module: started by trigger-log-watcher.service and never routed through drone. Its stdout is the journal, where rich markup would be noise. FILE-WIDE rather than line-scoped because the '--help not implemented' finding carries no line, so a `lines` key would cover the prints and silently drop the --help half." }, - { - "file": "apps/log_watcher_service.py", - "standard": "debug_print", - "lines": [ - 67, - 78, - 86, - 105, - 108 - ], - "pattern": "journald status lines", - "reason": "LINE-SCOPED AND HONOURED: debug_print_check filters per line. These are the daemon's lifecycle lines going to journald (signal received, both watchers failed, running, reloading on changed handler code, stopped). The print in the __main__ block the checker already exempts. Re-derived 2026-08-12 after the reload-sentinel wiring shifted every number and added the reload line - nudged numbers are how a line-scoped bypass rots." - }, - { - "file": "apps/log_watcher_service.py", - "standard": "help_text", - "lines": [ - 17 - ], - "pattern": "python -m in module docstring", - "reason": "LINE-SCOPED AND HONOURED: help_text_check filters per line. L17 documents how systemd's ExecStart actually launches this module. `drone @trigger ...` would be a lie — drone does not start this unit; the line below it gives the systemctl form." - }, { "file": "apps/config.py", "standard": "imports", @@ -171,6 +141,12 @@ ], "reason": "NAME-SCOPED AND HONOURED. The plain-list accessor beside get_volume_muted_branches_detail(); medic.py renders the detail variant, so only the 5 assertions in test_medic_state.py call this one. Kept as the symmetric half of the public read API — delete it and the tests if that symmetry is not worth the rule." }, + { + "file": "apps/handlers/cli/help_flags.py", + "standard": "json_structure", + "pattern": "pure predicate — no json_handler import", + "reason": "Pure argument inspection: no I/O, no state, no branch imports beyond typing, so it can be called as the FIRST thing in every handle_command before anything else runs. log_operation() here would write a line per CLI invocation into the operation log, and would make the help gate depend on the JSON layer it is meant to run ahead of. Same call and same reason as @memory's identical helper (their bypass.json, 2026-08-13); their file is the standard's own reference implementation." + }, { "file": "apps/log_watcher_service.py", "standard": "encapsulation", @@ -178,13 +154,28 @@ 26 ], "pattern": "import aipass.trigger.apps.handlers.reload_sentinel", - "reason": "LINE-SCOPED AND HONOURED: encapsulation_check is the one checker in the pack that passes a line to is_bypassed(). trigger publishes no modules/ entry point for the reload sentinel and should not - it is not a CLI surface, it is the service's own supervisor hook, while modules/ is auto-discovered for drone routing. Same shape as the ai_mail dispatch.wake exemption. Line drifts if anything above L26 changes - re-derive rather than nudge it." + "reason": "LINE-SCOPED AND HONOURED: encapsulation_check is the one checker in the pack that passes a line to is_bypassed(). trigger publishes no modules/ entry point for the reload sentinel and should not - it is not a CLI surface, it is the service's own supervisor hook, while modules/ is auto-discovered for drone routing. Same shape as the ai_mail dispatch.wake exemption. Line drifts if anything above L26 changes - re-derive rather than nudge it.", + "anchor": "import aipass.trigger.apps.handlers.reload_sentinel" }, { - "file": "apps/handlers/cli/help_flags.py", - "standard": "json_structure", - "pattern": "pure predicate — no json_handler import", - "reason": "Pure argument inspection: no I/O, no state, no branch imports beyond typing, so it can be called as the FIRST thing in every handle_command before anything else runs. log_operation() here would write a line per CLI invocation into the operation log, and would make the help gate depend on the JSON layer it is meant to run ahead of. Same call and same reason as @memory's identical helper (their bypass.json, 2026-08-13); their file is the standard's own reference implementation." + "file": "apps/log_watcher_service.py", + "standard": "help_text", + "lines": [ + 17 + ], + "pattern": "python -m in module docstring", + "reason": "LINE-SCOPED AND HONOURED: help_text_check filters per line. L17 documents how systemd's ExecStart actually launches this module. `drone @trigger ...` would be a lie — drone does not start this unit; the line below it gives the systemctl form.", + "anchor": "python -m aipass.trigger.apps.log_watcher_service" + }, + { + "file": "apps/handlers/events/runaway_handler.py", + "standard": "encapsulation", + "lines": [ + 336 + ], + "pattern": "from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch", + "reason": "LINE-SCOPED AND HONOURED: encapsulation_check is the one checker in the pack that passes a line to is_bypassed(). ai_mail publishes no modules/ entry point for waking a branch, so dispatch.wake is the only door. Line number drifts if anything above L344 changes — re-derive rather than nudge it. | LINE RE-DERIVED 2026-08-31: was [344], now [336]. The round-4 dead-cwd sweep deleted a _find_repo_root copy from this file and every line below it moved up; the waiver kept pointing at the old number and silently stopped suppressing.", + "anchor": "from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch" } ], "notes": { @@ -197,7 +188,8 @@ "functions": "Optional — function names, honoured by unused_function", "category": "Optional — read by trigger_check (e.g. handler_layer, initialization)", "pattern": "Optional — records the construct being bypassed. Documentation only; no checker reads it", - "reason": "Required — why this bypass exists, and why it is file-wide if it is" + "reason": "Required — why this bypass exists, and why it is file-wide if it is", + "anchor": "Required when 'lines' is set - the literal text that line must CONTAIN. seedgo does not read it; tests/test_bypass_anchors.py does, and reds the day an edit moves the line. A line number is a pointer that nothing validates: on 2026-08-31 four of these drifted silently and the suppressed violations resurfaced looking like new breakage." } } -} +} \ No newline at end of file diff --git a/src/aipass/trigger/apps/config.py b/src/aipass/trigger/apps/config.py index fd18af394..1ec35c02b 100644 --- a/src/aipass/trigger/apps/config.py +++ b/src/aipass/trigger/apps/config.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: config.py -# Description: Trigger package paths, atomic JSON writes, recursion-safe trail logger -# Version: 1.3.0 +# Description: Trigger package paths, cwd-free module resolve, atomic JSON writes, recursion-safe trail logger +# Version: 1.4.0 # Created: 2026-03-09 -# Modified: 2026-08-09 +# Modified: 2026-08-31 # ============================================= """ @@ -36,8 +36,52 @@ except Exception: _append_jsonl = None +# Resolve failures, recorded rather than logged: module_file runs before this +# module has a logger at all (TrailLogger is defined below it), and in the world +# that reaches its guard EVERY module import takes it — one line per module +# would describe a single condition N times. Introspectable instead. +UNRESOLVED: list[str] = [] + + +def module_file(file: str) -> Path: + """A module's own path, absolute, resolved when the filesystem allows. + + ``Path(__file__).resolve()`` reached at IMPORT is a working-directory read on + Windows: ntpath.realpath calls ``os.getcwd()`` UNCONDITIONALLY — not only for + relative paths, the way posixpath does — and ``Path.resolve()`` routes + through it. So a process whose cwd was deleted cannot import the module at + all. Measured on the Windows CI gate 2026-08-31 (@memory's finding); + @prax's import chain died inside trigger's handler guard and took every + consumer of the prax logger with it. + + ``.resolve()`` is still attempted, because normalising symlinks is why the + call is there and it succeeds on every healthy machine. The fallback is only + reached in the world where the alternative is a dead import, and it is + sound: ``__file__`` has been absolute since Python 3.9, so the return is the + right file either way — just spelled through the symlink rather than past it. + + It lives in config.py rather than in a handler because config.py is where + this branch's package paths are defined and because config.py must not + import handlers: TRIGGER_ROOT below is its first caller. + + Args: + file: A module's ``__file__``. + + Returns: + The module's path, resolved if the filesystem could be asked. + """ + path = Path(file) + try: + return path.resolve() + except OSError as exc: + UNRESOLVED.append(f"{path}: {type(exc).__name__}: {exc}") + return path + + # Trigger package root: .../aipass/trigger/ -TRIGGER_ROOT = Path(__file__).resolve().parents[1] +# +# module_file, not resolve(): this line runs at IMPORT. See module_file above. +TRIGGER_ROOT = module_file(__file__).parents[1] _CONFIG_LOG = TRIGGER_ROOT / "logs" / "config.jsonl" @@ -503,7 +547,8 @@ def print_introspection(): """Display module introspection info.""" try: from aipass.cli.apps.modules.display import console - except ImportError: + # OSError too: an uncured peer's import guard raises FileNotFoundError (@prax's rule, 2026-08-31). + except (ImportError, OSError): logger.warning("CLI console not available, using rich fallback") from rich.console import Console diff --git a/src/aipass/trigger/apps/handlers/__init__.py b/src/aipass/trigger/apps/handlers/__init__.py index 2cb80e33a..b935dff0b 100644 --- a/src/aipass/trigger/apps/handlers/__init__.py +++ b/src/aipass/trigger/apps/handlers/__init__.py @@ -1,26 +1,68 @@ """TRIGGER handlers package - Security protected.""" -import inspect +import linecache +import sys from pathlib import Path MY_BRANCH = "trigger" def _find_real_caller(): - """Walk the stack to find the actual file that triggered this import.""" - stack = inspect.stack() - this_file = str(Path(__file__).resolve()) - - for frame_info in stack: - filename = frame_info.filename - if this_file in str(Path(filename).resolve()): - continue + """Walk the stack to find the actual file that triggered this import. + + Skips this file, importlib internals, and frozen modules. + Returns tuple: (file_path, import_line) or (None, None). + + Walks frames with sys._getframe rather than inspect.stack(). MEASURED on the + Windows CI gate 2026-08-31 (@memory's finding, relayed by @prax and @devpulse + after prax's own import chain died here): inspect.stack() needs a READABLE + CWD, and it needs one before any of this function's own code runs. It builds + a FrameInfo per frame, which calls getsourcefile() -> getmodule() -> + os.path.realpath(); ntpath.realpath calls os.getcwd() UNCONDITIONALLY on its + first lines, before it even checks whether the path is absolute, and that + call site in getmodule is not inside a try. On POSIX the equivalent raise + happens earlier, inside getabsfile(), where inspect catches it — which is why + this was invisible on Linux for as long as it existed. A frame's co_filename + is already a string in memory; reading it touches nothing. + """ + # Path.resolve() reaches the same ntpath.realpath, so this is guarded too — + # __file__ is already absolute; the resolve only normalises it. + try: + this_file = str(Path(__file__).resolve()) + except OSError: + this_file = __file__ + + frame = sys._getframe(1) + while frame is not None: + filename = frame.f_code.co_filename + + # Skip Python internals BEFORE touching the filesystem — resolve() on a + # pseudo-filename like needs a cwd, and a process whose cwd was + # deleted dies here otherwise. if filename.startswith("<") or "importlib" in filename: + frame = frame.f_back + continue + + # resolve() on a relative frame filename also needs a cwd; fall back to + # the raw spelling rather than crashing every consumer's import. + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename + + if this_file in resolved or __file__ in filename: + frame = frame.f_back continue - import_line = None - if frame_info.code_context: - import_line = frame_info.code_context[0].strip() - return str(Path(filename).resolve()), import_line + + # linecache is what inspect used for code_context; called directly it + # reads one named file and returns "" rather than raising when it cannot. + try: + import_line = linecache.getline(filename, frame.f_lineno).strip() or None + except OSError: + import_line = None + + return resolved, import_line + return None, None @@ -43,16 +85,16 @@ def _guard_branch_access(): import os if os.environ.get("AIPASS_DEBUG_GUARD"): - import sys - print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) if caller_file is None: - stack = inspect.stack() - for frame in stack: - if frame.filename in ("", ""): - return # Allow command-line Python through + # No caller outside this file: an interactive session, a -c script, or an + # importlib-only stack. All three are allowed. This used to walk + # inspect.stack() a SECOND time looking for / and then + # return either way — both arms returned None with no side effect, so the + # walk could not change the answer. A cwd read computing a value nobody + # reads is pure exposure (@prax, 2026-08-31). Deleted. return if "/trigger/" in caller_file.replace("\\", "/"): diff --git a/src/aipass/trigger/apps/handlers/error_reporter.py b/src/aipass/trigger/apps/handlers/error_reporter.py index 827ddbdad..9b5f096f1 100644 --- a/src/aipass/trigger/apps/handlers/error_reporter.py +++ b/src/aipass/trigger/apps/handlers/error_reporter.py @@ -43,7 +43,8 @@ def send_source_fix_email(entry: dict) -> bool: """ try: from aipass.ai_mail.apps.modules.email_send import deliver_email_to_branch - except ImportError: + # OSError too: an uncured peer's import guard raises FileNotFoundError (@prax's rule, 2026-08-31). + except (ImportError, OSError): logger.info("[ERRORS] Could not import deliver_email_to_branch - ai_mail not available") return False diff --git a/src/aipass/trigger/apps/handlers/escalation.py b/src/aipass/trigger/apps/handlers/escalation.py index c1a745e93..ecf09c6ee 100644 --- a/src/aipass/trigger/apps/handlers/escalation.py +++ b/src/aipass/trigger/apps/handlers/escalation.py @@ -55,6 +55,7 @@ json_file_lock, trail_logger, ) +from aipass.trigger.apps.handlers.repo_root import find_repo_root from aipass.trigger.apps.handlers.json import config_loader from aipass.trigger.apps.handlers.json import json_handler @@ -115,15 +116,7 @@ def get_config() -> Dict[str, Any]: return cfg -def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - for parent in [TRIGGER_ROOT] + list(TRIGGER_ROOT.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() - - -BRANCH_REGISTRY_FILE = _find_repo_root() / "AIPASS_REGISTRY.json" +BRANCH_REGISTRY_FILE = find_repo_root(TRIGGER_ROOT, caller="escalation") / "AIPASS_REGISTRY.json" # Citizen names read from the registry, cached on a short TTL exactly like # get_config() above and for the same reason: this runs once per watched log diff --git a/src/aipass/trigger/apps/handlers/events/error_detected.py b/src/aipass/trigger/apps/handlers/events/error_detected.py index da3c5c26e..e13367950 100644 --- a/src/aipass/trigger/apps/handlers/events/error_detected.py +++ b/src/aipass/trigger/apps/handlers/events/error_detected.py @@ -47,6 +47,7 @@ migrate_json_file, trail_logger, ) +from aipass.trigger.apps.handlers.repo_root import find_repo_root from aipass.trigger.apps.handlers.json import json_handler from aipass.trigger.apps.handlers import escalation @@ -56,16 +57,7 @@ logger = trail_logger(TRIGGER_ROOT / "logs" / "error_detected_handler.jsonl") -def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() - - -_REPO_ROOT = _find_repo_root() +_REPO_ROOT = find_repo_root(caller="error_detected") BRANCH_REGISTRY_FILE = _REPO_ROOT / "AIPASS_REGISTRY.json" diff --git a/src/aipass/trigger/apps/handlers/events/plan_file.py b/src/aipass/trigger/apps/handlers/events/plan_file.py deleted file mode 100644 index a20fae3ab..000000000 --- a/src/aipass/trigger/apps/handlers/events/plan_file.py +++ /dev/null @@ -1,204 +0,0 @@ -# =================== AIPass ==================== -# Name: plan_file.py -# Description: PLAN file event handlers for registry updates -# Version: 1.1.0 -# Created: 2026-01-20 -# Modified: 2026-08-09 -# ============================================= - -""" -PLAN File Event Handlers - -Handles filesystem events for PLAN files and updates Flow's registry. - -Events handled: -- plan_file_created: New PLAN file detected -- plan_file_deleted: PLAN file removed -- plan_file_moved: PLAN file moved/renamed - -Architecture: -- Flow fires these events via trigger.fire() -- Trigger handlers update Flow's registry -- Decoupled: Flow doesn't know what happens after firing -""" - -import re -from datetime import datetime, timezone -from pathlib import Path -from typing import Optional -from aipass.trigger.apps.config import TRIGGER_ROOT, AIPASS_PKG_ROOT, atomic_write_json, trail_logger -from aipass.trigger.apps.handlers.json import json_handler - - -def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() - - -REPO_ROOT = _find_repo_root() - -# Registry JSON file path (direct file access, no handler imports) -FLOW_JSON_DIR = AIPASS_PKG_ROOT / "flow" / "flow_json" -REGISTRY_FILE = FLOW_JSON_DIR / "PLAN_REGISTRY.json" - -HANDLER_LOG = TRIGGER_ROOT / "logs" / "plan_file_handler.jsonl" - -MODULE_NAME = "trigger.plan_file" - - -# Deliberately NOT prax: this handler runs on the event path the log watchers -# read, so a line through prax would be detected and fired straight back at it. -# The sidecar is `.jsonl`, which the watchers skip — they read only `*.log`. -logger = trail_logger(HANDLER_LOG) - - -def _load_registry() -> dict: - """Load registry from JSON file""" - import json - - if REGISTRY_FILE.exists(): - with open(REGISTRY_FILE, "r", encoding="utf-8") as f: - return json.load(f) - return {"plans": {}, "next_number": 1} - - -def _save_registry(registry: dict) -> None: - """Save registry to JSON file""" - atomic_write_json(REGISTRY_FILE, registry) - - -def _get_plan_number(file_path: Path) -> Optional[str]: - """Extract plan number from filename (e.g., FPLAN-0001.md -> 0001)""" - match = re.search(r"FPLAN-(\d{4})\.md$", file_path.name) - return match.group(1) if match else None - - -def handle_plan_file_created(path: str, **kwargs): - """ - Handle new PLAN file creation - - Args: - path: Absolute path to the new PLAN file - """ - file_path = Path(path) - plan_number = _get_plan_number(file_path) - - if not plan_number: - return - - try: - registry = _load_registry() - - # Check if already exists - if plan_number in registry.get("plans", {}): - existing_plan = registry["plans"][plan_number] - - # If plan is closed, preserve closed status and just update location - if existing_plan.get("status") == "closed": - existing_plan["location"] = str(file_path.parent) - existing_plan["relative_path"] = str(file_path.parent.relative_to(REPO_ROOT)) - existing_plan["file_path"] = str(file_path) - existing_plan["last_updated"] = datetime.now(timezone.utc).isoformat() - _save_registry(registry) - return - else: - return - - # Add to registry - relative_path = str(file_path.parent.relative_to(REPO_ROOT)) - registry.setdefault("plans", {})[plan_number] = { - "location": str(file_path.parent), - "relative_path": relative_path, - "created": datetime.now(timezone.utc).isoformat(), - "subject": "Auto-detected PLAN", - "status": "open", - "file_path": str(file_path), - "last_updated": datetime.now(timezone.utc).isoformat(), - } - - # Update next_number if needed - current_next = registry.get("next_number", 1) - plan_num_int = int(plan_number) - if plan_num_int >= current_next: - registry["next_number"] = plan_num_int + 1 - - _save_registry(registry) - - json_handler.log_operation("plan_event", {"success": True}) - - except Exception as e: - logger.error(f"handle_plan_file_created failed for {path}: {e}", module=MODULE_NAME) - - -def handle_plan_file_deleted(path: str, **kwargs): - """ - Handle PLAN file deletion - - Args: - path: Absolute path to the deleted PLAN file - """ - file_path = Path(path) - plan_number = _get_plan_number(file_path) - - if not plan_number: - return - - try: - registry = _load_registry() - plans = registry.get("plans", {}) - - if plan_number in plans: - plan_info = plans[plan_number] - - # If plan is closed/processed, preserve it in registry but mark as archived - if plan_info.get("status") == "closed" or plan_info.get("processed"): - plan_info["archived"] = True - plan_info["archived_date"] = datetime.now(timezone.utc).isoformat() - plan_info["last_updated"] = datetime.now(timezone.utc).isoformat() - _save_registry(registry) - else: - # Plan is open but file deleted - remove from registry completely - del plans[plan_number] - registry["plans"] = plans - _save_registry(registry) - - except Exception as e: - logger.error(f"handle_plan_file_deleted failed for {path}: {e}", module=MODULE_NAME) - - -def handle_plan_file_moved(src_path: str, dest_path: str, **kwargs): - """ - Handle PLAN file move/rename - - Args: - src_path: Original path of the PLAN file - dest_path: New path of the PLAN file - """ - dest_file = Path(dest_path) - plan_number = _get_plan_number(dest_file) - - if not plan_number: - return - - try: - registry = _load_registry() - plans = registry.get("plans", {}) - - if plan_number in plans: - relative_path = str(dest_file.parent.relative_to(REPO_ROOT)) - - # CRITICAL: Only update location fields, preserve ALL other metadata - # (status, closed, closed_reason, memory_created, memory_created_date, etc.) - plans[plan_number]["location"] = str(dest_file.parent) - plans[plan_number]["relative_path"] = relative_path - plans[plan_number]["file_path"] = str(dest_file) - plans[plan_number]["last_updated"] = datetime.now(timezone.utc).isoformat() - - _save_registry(registry) - - except Exception as e: - logger.error(f"handle_plan_file_moved failed for {src_path} -> {dest_path}: {e}", module=MODULE_NAME) diff --git a/src/aipass/trigger/apps/handlers/events/registry.py b/src/aipass/trigger/apps/handlers/events/registry.py index 07378ea01..d82707cf5 100644 --- a/src/aipass/trigger/apps/handlers/events/registry.py +++ b/src/aipass/trigger/apps/handlers/events/registry.py @@ -23,7 +23,6 @@ def setup_handlers(): from aipass.trigger.apps.modules.core import trigger from .startup import handle_startup from .cli import handle_cli_header_displayed - from .plan_file import handle_plan_file_created, handle_plan_file_deleted, handle_plan_file_moved from .error_detected import handle_error_detected, set_send_email_callback from .runaway_handler import handle_runaway_log_detected, set_send_email_callback as set_runaway_email_callback from aipass.trigger.apps.handlers.escalation import set_send_email_callback as set_escalation_email_callback @@ -76,7 +75,8 @@ def _send_email_adapter( # Escalation digests go out through the same adapter with # auto_execute=False — an email to a manager, never a wake. set_escalation_email_callback(_send_email_adapter) - except ImportError: + # OSError too: an uncured peer's import guard raises FileNotFoundError (@prax's rule, 2026-08-31). + except (ImportError, OSError): logger.warning("ai_mail not available — error notifications won't send") from .warning_logged import handle_warning_logged from .memory_template_updated import handle_memory_template_updated @@ -86,9 +86,11 @@ def _send_email_adapter( trigger.on("startup", handle_startup) trigger.on("cli_header_displayed", handle_cli_header_displayed) - trigger.on("plan_file_created", handle_plan_file_created) - trigger.on("plan_file_deleted", handle_plan_file_deleted) - trigger.on("plan_file_moved", handle_plan_file_moved) + # plan_file_created / _deleted / _moved are NOT wired. The handler was + # retired 2026-08-31 as measured inert — see .archive/plan_file.py for the + # evidence (its regex matched 1 of 366 real plan filenames). @flow fires + # these events from their own registry scan and needs nothing back, so the + # events stay in the vocabulary and simply run no handler. trigger.on("error_detected", handle_error_detected) trigger.on("warning_logged", handle_warning_logged) trigger.on("memory_template_updated", handle_memory_template_updated) diff --git a/src/aipass/trigger/apps/handlers/events/runaway_handler.py b/src/aipass/trigger/apps/handlers/events/runaway_handler.py index e77f66d9f..b0a51509e 100644 --- a/src/aipass/trigger/apps/handlers/events/runaway_handler.py +++ b/src/aipass/trigger/apps/handlers/events/runaway_handler.py @@ -76,6 +76,7 @@ migrate_json_file, trail_logger, ) +from aipass.trigger.apps.handlers.repo_root import find_repo_root from aipass.trigger.apps.handlers.json import json_handler DECISION_LOG = TRIGGER_ROOT / "logs" / "runaway_suppressed.jsonl" @@ -90,16 +91,7 @@ logger = trail_logger(TRIGGER_ROOT / "logs" / "runaway_handler.jsonl") -def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() - - -_REPO_ROOT = _find_repo_root() +_REPO_ROOT = find_repo_root(caller="runaway_handler") ALERTS_FILE = _REPO_ROOT / ".aipass" / "alerts.json" # Live medic state — see medic_state.py for why it is not on the trio path. MEDIC_STATE_FILE = TRIGGER_JSON_DIR / "medic_state.json" diff --git a/src/aipass/trigger/apps/handlers/json/json_handler.py b/src/aipass/trigger/apps/handlers/json/json_handler.py index e55dbb30e..975412302 100644 --- a/src/aipass/trigger/apps/handlers/json/json_handler.py +++ b/src/aipass/trigger/apps/handlers/json/json_handler.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: json_handler.py # Description: JSON auto-creating handler for trigger data files -# Version: 1.2.0 +# Version: 1.3.0 # Created: 2025-11-13 -# Modified: 2026-08-09 +# Modified: 2026-08-31 # ============================================= """JSON auto-creating handler for trigger data files.""" @@ -14,12 +14,12 @@ from pathlib import Path from datetime import datetime from typing import Dict, Any, Optional -import inspect from aipass.trigger.apps.config import ( atomic_create_json, atomic_write_json, json_file_lock, + module_file, read_text_with_retry, trail_logger, ) @@ -45,7 +45,8 @@ # Constants -TRIGGER_ROOT = Path(__file__).resolve().parents[3] +# module_file, not resolve(): import-time cwd read on Windows (repo_root.py). +TRIGGER_ROOT = module_file(__file__).parents[3] TRIGGER_JSON_DIR = TRIGGER_ROOT / "trigger_json" @@ -56,12 +57,22 @@ def _get_caller_module_name() -> str: Returns: Module name (e.g., "imports_standard" from imports_standard.py) """ - stack = inspect.stack() + # sys._getframe, not inspect.stack(): inspect.stack() builds a FrameInfo for + # EVERY frame, and each one reaches getmodule() -> os.path.realpath(), which + # on Windows reads os.getcwd() unconditionally and outside any try. This runs + # on the log_operation hot path, so the old spelling was both a cwd + # dependency and a full-stack walk to read one filename. Same measurement as + # handlers/repo_root.py; a frame's co_filename is already a string in memory. # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller - if len(stack) > 2: - caller_frame = stack[2] - caller_path = Path(caller_frame.filename) - module_name = caller_path.stem + try: + frame = sys._getframe(2) + except ValueError as exc: + # Stack shallower than the documented shape — the caller is unknown, and + # saying so is the answer. inspect.stack() expressed this as len() > 2. + logger.info(f"caller module name unavailable ({exc}) — recording as unknown") + frame = None + if frame is not None: + module_name = Path(frame.f_code.co_filename).stem # Validate module name if module_name and not module_name.startswith("_"): diff --git a/src/aipass/trigger/apps/handlers/repo_root.py b/src/aipass/trigger/apps/handlers/repo_root.py new file mode 100644 index 000000000..f5d592d53 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/repo_root.py @@ -0,0 +1,201 @@ +# =================== AIPass ==================== +# Name: repo_root.py +# Description: One guarded answer to "where is my file" and "where is the repo root" — never the process cwd +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""Module-file and repo-root resolution, defined once for trigger. + +WHY THIS FILE EXISTS +-------------------- +Two defects, one line apart, found the same week. + +THE CRASH. ``ntpath.realpath`` reads ``os.getcwd()`` UNCONDITIONALLY — not only +for relative paths, the way ``posixpath`` does — and ``Path.resolve()`` routes +through it. So on Windows every ``Path(__file__).resolve()`` REACHED AT IMPORT +is an import-time working-directory dependency: a process whose cwd was deleted +cannot import the module at all. Measured on the Windows CI gate 2026-08-31 +(@memory's finding); @prax's own import chain died inside trigger's handler +guard and took every prax consumer with it. + +THE QUIET ONE, which would have outlived the crash. Four byte-identical copies +of ``_find_repo_root`` in this tree ended in ``return Path.cwd()``. The +directory a process happened to start in says nothing about where this source +file lives, so on a registry-less tree (``AIPASS_REGISTRY.json`` is gitignored +and machine-local, so every clean clone and CI runner is one) each caller +resolved against whatever the shell was pointing at — silently, and differently +per caller. A ``try``/``except`` would have fixed the traceback and kept the +wrong answer. + +FOUR COPIES IS THE DEFECT, NOT FOUR BUGS +---------------------------------------- +The copies lived in ``escalation.py``, ``events/error_detected.py``, +``events/runaway_handler.py`` and ``events/plan_file.py``, all four evaluated at +MODULE level. (The fourth was retired outright the same day, on separate +evidence — see ``events/.archive/plan_file.py``.) A cure landing on some of N identical paths is the species this +branch spent the week naming in other people's trees; one implementation is the +only version of it that cannot recur. + +A FILENAME IS NOT AN ``exists()`` +--------------------------------- +Windows and macOS-default filesystems fold case, so +``(parent / "AIPASS_REGISTRY.json").exists()`` returns True for a file actually +named ``aipass_registry.json``. That is worse than the glob form swept fleet-wide +in ebb8075d because there is no pattern in the line to warn a reader. The walk +below decides which installation this branch belongs to and several callers +build WRITE paths from the answer, so a folded bait file would be accepted as +THE repo root. ``exists_exactly`` is @memory's cure, adopted here rather than +re-invented. + +WHERE THE TWO HALVES LIVE, AND WHY +---------------------------------- +``module_file`` — the guarded ``__file__`` resolve — lives in ``apps/config.py``, +not here. config.py is where this branch's package paths are defined, its own +``TRIGGER_ROOT`` is the first caller, and config.py must not import a handler. +This module is the WALK, which is handler-layer work; it imports the resolve +from config like every other handler does. + +NO PRAX. Every caller here sits on the event path the log watchers read, so a +line through prax would be detected and fired straight back at this branch — +and config.py, which this module imports, cannot import the prax logger at all +(circular). The fallback is reported through the ``.jsonl`` trail sidecar the +callers already use; the watchers read only ``*.log``. That write is itself +guarded, because four callers reach it at IMPORT time and a diagnostic line must +never become the import crash this module exists to prevent. +""" + +import os +from pathlib import Path + +from aipass.trigger.apps.config import TRIGGER_ROOT, module_file, trail_logger + +MODULE_NAME = "repo_root" + +# Infrastructure — redirect to temp dir during tests, the same door +# json_handler already uses. Measured, not assumed: the first version of this +# module wrote 38 lines into the live tree while its own pins ran. +_test_log_dir = os.environ.get("AIPASS_TEST_LOG_DIR") +if _test_log_dir: + _LOG_FILE = Path(_test_log_dir) / "trigger" / "repo_root.jsonl" +else: + _LOG_FILE = TRIGGER_ROOT / "logs" / "repo_root.jsonl" + +logger = trail_logger(_LOG_FILE) + +# Observable state for the one guard that has nothing left to call. See +# _record_fallback: it is the arm that fires when the LOGGER itself failed. +RECORD_FAILURES: list[str] = [] + +# The marker that defines a repo root: the core registry file. +CORE_REGISTRY = "AIPASS_REGISTRY.json" + + +_THIS_FILE = module_file(__file__) + +# The repo root implied by THIS FILE's location, derived from the package layout +# and nothing else. ``src/`` is the marker because it is the one directory the +# layout guarantees. The last-resort value is the filesystem root: defined, +# never raises, and absurd enough to fail loudly downstream instead of quietly +# resolving against somebody's home directory. +SOURCE_ROOT = next( + (parent.parent for parent in _THIS_FILE.parents if parent.name == "src"), + _THIS_FILE.parents[-1], +) + + +def exists_exactly(path: Path) -> bool: + """True when *path* exists AND is spelled on disk exactly as asked. + + ``Path.exists()`` asks the filesystem, and Windows and macOS-default + filesystems answer about a case-folded name. The only reliable way to learn + the real spelling is to LIST the parent — ``resolve()`` would work on Windows + but follows symlinks, so a legitimately symlinked registry would come back + under its target's name and be refused. + + An unlistable parent returns True rather than False. This is a READ anchor, + and today's behaviour is ``exists()`` alone; refusing a file that is + demonstrably there because its directory could not be enumerated would be a + new failure invented by the guard. + + Args: + path: The exact filename being asserted. + + Returns: + True when a directory entry with that exact name exists. + """ + candidate = Path(path) + if not candidate.exists(): + return False + try: + with os.scandir(candidate.parent) as entries: + return any(entry.name == candidate.name for entry in entries) + except OSError: + return True + + +def _record_fallback(caller: str, marker: str, current: Path) -> None: + """Record the fallback without ever raising. + + Runs at module import time on all four callers, so every failure mode here + is swallowed: an operations record is worth having, and it is never worth + turning a diagnostic write into the import crash this module prevents. + + Args: + caller: Lane that took the fallback. + marker: Filename that was searched for. + current: Directory the walk started from. + """ + try: + logger.warning( + f"[{caller}] No {marker} above {current} — " + f"resolving to the source tree at {SOURCE_ROOT}, never the process directory" + ) + # The operations record, in @memory's ratified shape. Function-local + # because json_handler imports config and config is imported above — + # a module-level edge would close the loop, and it would only show up + # in whichever import order CI happened to take. + from aipass.trigger.apps.handlers.json import json_handler + + json_handler.log_operation( + "repo_root_fallback", + {"caller": caller, "marker": marker, "searched_from": str(current), "resolved": str(SOURCE_ROOT)}, + module_name=MODULE_NAME, + ) + except Exception as exc: # noqa: BLE001 - an audit line must never take an import down + # The record of the failed record. Not a swallow: the reason is kept + # where a reader can find it, because the alternative — raising from a + # diagnostic line reached at import — is the crash this module prevents. + RECORD_FAILURES.append(f"{caller}/{marker}: {type(exc).__name__}: {exc}") + + +def find_repo_root(start: Path | None = None, *, marker: str = CORE_REGISTRY, caller: str = MODULE_NAME) -> Path: + """Walk up from *start* to the directory holding *marker*. + + Falls back to the root implied by THIS FILE's location — never to the process + working directory. See the module docstring for the two defects that fallback + carried. + + Args: + start: Directory to walk up from. Defaults to this file's directory, + which is what every caller in this tree wants: they all live under + the same ``src/aipass/trigger`` package, so it is the same walk + whichever of them asks. + marker: Filename that marks a repo root. + caller: Name used in the log line, so a fallback names the lane that took + it rather than reporting anonymously. + + Returns: + The directory holding *marker*, or ``SOURCE_ROOT`` when no *marker* + exists anywhere above *start*. Never reads the process cwd. + """ + # module_file, not resolve(): every caller reaches this line at IMPORT time, + # and on Windows resolve() reads the working directory. The discriminator + # that matters is REACHED AT IMPORT, not written at module scope. + current = Path(start) if start is not None else _THIS_FILE.parent + for parent in [current] + list(current.parents): + if exists_exactly(parent / marker): + return parent + _record_fallback(caller, marker, current) + return SOURCE_ROOT diff --git a/src/aipass/trigger/apps/handlers/service_control.py b/src/aipass/trigger/apps/handlers/service_control.py new file mode 100644 index 000000000..4267c6a29 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/service_control.py @@ -0,0 +1,115 @@ +# =================== AIPass ==================== +# Name: service_control.py +# Description: systemd unit lifecycle for the trigger log watcher +# Version: 1.0.0 +# Created: 2026-08-31 +# Modified: 2026-08-31 +# ============================================= + +"""The systemd user unit behind the log watcher, and nothing else. + +Extracted from ``modules/medic.py`` 2026-08-31. medic.py had grown to 599 lines +— two hundred past the point the standard asks you to split by domain — and it +was carrying two unrelated jobs: the medic CLI (on/off/status/mute) and the +management of a systemd unit. This is the second one. It is a handler because +installing a unit file and shelling out to systemctl is an implementation +detail; which branch is muted is not. + +Every name here is imported back into medic under its original spelling, so +``patch.object(medic, "_systemctl", ...)`` in the suite still binds the symbol +medic's own code resolves. The move is a move: no behaviour changed with it. +""" + +import os +import subprocess +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.trigger.apps.config import TRIGGER_ROOT +from aipass.trigger.apps.handlers.json import json_handler +from aipass.trigger.apps.handlers.repo_root import find_repo_root + +MODULE_NAME = "service_control" + +SERVICE_NAME = "trigger-log-watcher.service" +_SERVICE_UNIT_PATH = Path.home() / ".config" / "systemd" / "user" / SERVICE_NAME +_TEMPLATE_PATH = TRIGGER_ROOT / "templates" / f"{SERVICE_NAME}.template" + + +def _get_aipass_home() -> Path: + """Resolve AIPASS_HOME from env var or git repo root.""" + env = os.environ.get("AIPASS_HOME") + if env: + return Path(env) + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + except Exception as exc: + logger.warning("[MEDIC] git repo root detection failed: %s", exc) + return find_repo_root(caller="medic") + + +def _ensure_service_installed() -> bool: + """Install systemd unit from template if missing. Returns True if ready.""" + if _SERVICE_UNIT_PATH.exists(): + return True + + if not _TEMPLATE_PATH.exists(): + logger.warning("[MEDIC] Service template not found: %s", _TEMPLATE_PATH) + return False + + aipass_home = _get_aipass_home() + from aipass.trigger.apps.config import read_text_file, write_text_file + + template = read_text_file(_TEMPLATE_PATH) + rendered = template.replace("{{AIPASS_HOME}}", str(aipass_home)) + + write_text_file(_SERVICE_UNIT_PATH, rendered) + logger.info("[MEDIC] Installed systemd unit to %s", _SERVICE_UNIT_PATH) + json_handler.log_operation( + "systemd_unit_installed", + {"unit": str(_SERVICE_UNIT_PATH), "aipass_home": str(aipass_home)}, + module_name=MODULE_NAME, + ) + + _systemctl("daemon-reload") + subprocess.run( + ["systemctl", "--user", "enable", SERVICE_NAME], + capture_output=True, + text=True, + timeout=10, + ) + return True + + +def _systemctl(action: str) -> bool: + """Run systemctl --user action on the log watcher service. + + Args: + action: systemctl action (start, stop, restart, is-active) + + Returns: + True if command succeeded (exit code 0) + """ + try: + result = subprocess.run( + ["systemctl", "--user", action, SERVICE_NAME], + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 + except Exception as exc: + logger.warning(f"[MEDIC] systemctl {action} failed: {exc}") + return False + + +def _is_service_active() -> bool: + """Check if the log watcher systemd service is running.""" + return _systemctl("is-active") diff --git a/src/aipass/trigger/apps/log_watcher_service.py b/src/aipass/trigger/apps/log_watcher_service.py index 20447ece1..738ab2597 100644 --- a/src/aipass/trigger/apps/log_watcher_service.py +++ b/src/aipass/trigger/apps/log_watcher_service.py @@ -1,9 +1,9 @@ # =================== AIPass ==================== # Name: log_watcher_service.py # Description: Persistent log watcher process for Medic error detection -# Version: 1.1.0 +# Version: 1.2.0 # Created: 2026-03-29 -# Modified: 2026-08-12 +# Modified: 2026-08-31 # ============================================= """ @@ -46,7 +46,8 @@ def print_introspection(): """Display module introspection info.""" try: from aipass.cli.apps.modules.display import console - except ImportError: + # OSError too: an uncured peer's import guard raises FileNotFoundError (@prax's rule, 2026-08-31). + except (ImportError, OSError): logger.info("CLI console not available, using rich fallback") from rich.console import Console @@ -58,13 +59,36 @@ def print_introspection(): console.print() +# LIFECYCLE OUTPUT GOES TO PRAX, NOT THE JOURNAL (2026-08-31) +# +# Five bare print() calls used to write this service's lifecycle to stdout and +# stderr, where systemd captured them into the journal. They are prax logger +# calls below. The standard's complaint was exact rather than stylistic: those +# lines were unstructured and invisible to monitoring, and prax IS this system's +# monitoring surface — so `drone @prax monitor` could not see the state of the +# process that feeds it. +# +# THE TRADE, stated rather than glossed: `journalctl --user -u +# trigger-log-watcher.service` no longer carries "Running (... watchers active)", +# "Received signal N", or "Stopped". systemd's own Started / Stopping / Stopped +# and exit-status lines remain, so the journal still answers "is the process up". +# It no longer answers "did the watchers actually come up" — that is +# `drone @trigger medic status`, which reads the service, and the prax log. +# +# The startup failure is the one message here that is NOT info: it is logged at +# ERROR so it reaches the registry, and the level is pinned by +# test_both_fail_is_reported_at_error_level. +# +# The `--version` print at the bottom stays: it sits inside +# `if __name__ == "__main__"`, which the standard excludes, and a version query +# answering on stdout is what a caller parsing it expects. def main() -> None: """Start watchers and block until signaled.""" stop_event = threading.Event() def shutdown(signum: int, _frame: object) -> None: """Handle SIGTERM/SIGINT gracefully.""" - print(f"[trigger-log-watcher] Received signal {signum}, shutting down...") + logger.info(f"[trigger-log-watcher] Received signal {signum}, shutting down...") stop_event.set() signal.signal(signal.SIGTERM, shutdown) @@ -75,7 +99,7 @@ def shutdown(signum: int, _frame: object) -> None: system_ok = start_system_watcher() if not branch_ok and not system_ok: - print("[trigger-log-watcher] Both watchers failed to start", file=sys.stderr) + logger.error("[trigger-log-watcher] Both watchers failed to start") sys.exit(1) started = [] @@ -83,7 +107,7 @@ def shutdown(signum: int, _frame: object) -> None: started.append("branch") if system_ok: started.append("system") - print(f"[trigger-log-watcher] Running ({', '.join(started)} watchers active)") + logger.info(f"[trigger-log-watcher] Running ({', '.join(started)} watchers active)") # Watch our OWN handler code for changes. This process holds those modules # in memory for its whole life, so a fix shipped to disk does nothing until @@ -102,10 +126,12 @@ def shutdown(signum: int, _frame: object) -> None: if reload_requested(): # NOT a clean exit on purpose: the unit ships Restart=on-failure, which # would read 0 as "job done" and leave the watcher down. - print(f"[trigger-log-watcher] Handler code changed — exiting {reload_sentinel.RELOAD_EXIT_CODE} to reload") + logger.info( + f"[trigger-log-watcher] Handler code changed — exiting {reload_sentinel.RELOAD_EXIT_CODE} to reload" + ) sys.exit(reload_sentinel.RELOAD_EXIT_CODE) - print("[trigger-log-watcher] Stopped") + logger.info("[trigger-log-watcher] Stopped") if __name__ == "__main__": diff --git a/src/aipass/trigger/apps/modules/branch_log_events.py b/src/aipass/trigger/apps/modules/branch_log_events.py index c82ab7f61..7ec1a4702 100644 --- a/src/aipass/trigger/apps/modules/branch_log_events.py +++ b/src/aipass/trigger/apps/modules/branch_log_events.py @@ -46,7 +46,8 @@ def print_introspection(): """Display module introspection info.""" try: from aipass.cli.apps.modules.display import console - except ImportError: + # OSError too: an uncured peer's import guard raises FileNotFoundError (@prax's rule, 2026-08-31). + except (ImportError, OSError): logger.info("CLI console not available, using rich fallback") from rich.console import Console diff --git a/src/aipass/trigger/apps/modules/core.py b/src/aipass/trigger/apps/modules/core.py index bfe1cb0af..2316fa772 100644 --- a/src/aipass/trigger/apps/modules/core.py +++ b/src/aipass/trigger/apps/modules/core.py @@ -32,7 +32,8 @@ def print_introspection(): """Display module introspection info.""" try: from aipass.cli.apps.modules.display import console - except ImportError: + # OSError too: an uncured peer's import guard raises FileNotFoundError (@prax's rule, 2026-08-31). + except (ImportError, OSError): from rich.console import Console console = Console() diff --git a/src/aipass/trigger/apps/modules/errors.py b/src/aipass/trigger/apps/modules/errors.py index 9e26acfc9..502da1459 100644 --- a/src/aipass/trigger/apps/modules/errors.py +++ b/src/aipass/trigger/apps/modules/errors.py @@ -56,7 +56,8 @@ def print_introspection(): """Display module introspection info.""" try: from aipass.cli.apps.modules.display import console - except ImportError: + # OSError too: an uncured peer's import guard raises FileNotFoundError (@prax's rule, 2026-08-31). + except (ImportError, OSError): logger.info("CLI console not available, using rich fallback") from rich.console import Console diff --git a/src/aipass/trigger/apps/modules/escalation.py b/src/aipass/trigger/apps/modules/escalation.py index 5f5b82ef8..ddaeebe1a 100644 --- a/src/aipass/trigger/apps/modules/escalation.py +++ b/src/aipass/trigger/apps/modules/escalation.py @@ -39,7 +39,8 @@ def print_introspection(): """Display module introspection info.""" try: from aipass.cli.apps.modules.display import console - except ImportError: + # OSError too: an uncured peer's import guard raises FileNotFoundError (@prax's rule, 2026-08-31). + except (ImportError, OSError): logger.info("CLI console not available, using rich fallback") from rich.console import Console diff --git a/src/aipass/trigger/apps/modules/log_events.py b/src/aipass/trigger/apps/modules/log_events.py index 7f6694331..fb54c5e6a 100644 --- a/src/aipass/trigger/apps/modules/log_events.py +++ b/src/aipass/trigger/apps/modules/log_events.py @@ -43,7 +43,8 @@ def print_introspection(): """Display module introspection info.""" try: from aipass.cli.apps.modules.display import console - except ImportError: + # OSError too: an uncured peer's import guard raises FileNotFoundError (@prax's rule, 2026-08-31). + except (ImportError, OSError): logger.info("CLI console not available, using rich fallback") from rich.console import Console diff --git a/src/aipass/trigger/apps/modules/medic.py b/src/aipass/trigger/apps/modules/medic.py index 3ccfb88cb..03f72a7e5 100644 --- a/src/aipass/trigger/apps/modules/medic.py +++ b/src/aipass/trigger/apps/modules/medic.py @@ -23,12 +23,16 @@ """ import os -import subprocess import sys from pathlib import Path from aipass.prax.apps.modules.logger import system_logger as logger from aipass.trigger.apps.handlers.cli.help_flags import wants_help +from aipass.trigger.apps.handlers.service_control import ( + _ensure_service_installed, + _is_service_active, + _systemctl, +) from aipass.trigger.apps.handlers.json import json_handler from aipass.trigger.apps.handlers.medic_state import ( @@ -55,65 +59,13 @@ if _reconfigure is not None: _reconfigure(encoding="utf-8", errors="replace") -SERVICE_NAME = "trigger-log-watcher.service" -_SERVICE_UNIT_PATH = Path.home() / ".config" / "systemd" / "user" / SERVICE_NAME -_TEMPLATE_PATH = Path(__file__).resolve().parent.parent.parent / "templates" / f"{SERVICE_NAME}.template" - - -def _get_aipass_home() -> Path: - """Resolve AIPASS_HOME from env var or git repo root.""" - import os - - env = os.environ.get("AIPASS_HOME") - if env: - return Path(env) - try: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - except Exception as exc: - logger.warning("[MEDIC] git repo root detection failed: %s", exc) - return Path(__file__).resolve().parent.parent.parent.parent.parent - - -def _ensure_service_installed() -> bool: - """Install systemd unit from template if missing. Returns True if ready.""" - if _SERVICE_UNIT_PATH.exists(): - return True - - if not _TEMPLATE_PATH.exists(): - logger.warning("[MEDIC] Service template not found: %s", _TEMPLATE_PATH) - return False - - aipass_home = _get_aipass_home() - from aipass.trigger.apps.config import read_text_file, write_text_file - - template = read_text_file(_TEMPLATE_PATH) - rendered = template.replace("{{AIPASS_HOME}}", str(aipass_home)) - - write_text_file(_SERVICE_UNIT_PATH, rendered) - logger.info("[MEDIC] Installed systemd unit to %s", _SERVICE_UNIT_PATH) - - _systemctl("daemon-reload") - subprocess.run( - ["systemctl", "--user", "enable", SERVICE_NAME], - capture_output=True, - text=True, - timeout=10, - ) - return True - def print_introspection(): """Display module introspection info.""" try: from aipass.cli.apps.modules.display import console - except ImportError: + # OSError too: an uncured peer's import guard raises FileNotFoundError (@prax's rule, 2026-08-31). + except (ImportError, OSError): logger.info("CLI console not available, using rich fallback") from rich.console import Console @@ -137,33 +89,6 @@ def print_introspection(): console.print() -def _systemctl(action: str) -> bool: - """Run systemctl --user action on the log watcher service. - - Args: - action: systemctl action (start, stop, restart, is-active) - - Returns: - True if command succeeded (exit code 0) - """ - try: - result = subprocess.run( - ["systemctl", "--user", action, SERVICE_NAME], - capture_output=True, - text=True, - timeout=10, - ) - return result.returncode == 0 - except Exception as exc: - logger.warning(f"[MEDIC] systemctl {action} failed: {exc}") - return False - - -def _is_service_active() -> bool: - """Check if the log watcher systemd service is running.""" - return _systemctl("is-active") - - def _extract_branch_name(raw: str) -> str: """ Extract branch name from raw argument. diff --git a/src/aipass/trigger/tests/test_bypass_anchors.py b/src/aipass/trigger/tests/test_bypass_anchors.py new file mode 100644 index 000000000..0042f8122 --- /dev/null +++ b/src/aipass/trigger/tests/test_bypass_anchors.py @@ -0,0 +1,123 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_bypass_anchors.py - line-scoped waivers must still point at their reason +# Date: 2026-08-31 +# Version: 1.1.0 +# Category: trigger/tests +# ============================================= + +"""A line-scoped seedgo waiver fails OPEN and SILENT when its line moves. + +MEASURED HERE 2026-08-31, round 5. The round-4 dead-cwd sweep added comment +lines to files that carried waivers keyed on line numbers. Every waived line +shifted - the log_watcher_service prints by +1, runaway_handler by -8 where a +dead _find_repo_root copy was deleted - and all four rules silently stopped +matching. The suppressed violations resurfaced and read as fresh breakage in +files nobody had touched semantically. Nothing warned: a rule matching no line +is indistinguishable from a rule doing its job. + +Re-deriving fixed that day and left the next edit free to break it the same way, +so the waivers now carry an `anchor` - the literal text the waived line must +contain - and this file reds when a line moves out from under one. seedgo does +NOT read `anchor` (bypass_handler matches on `lines` alone); this test is the +only thing that does, which is why it lives here rather than in the standard. + +The structural fix belongs in the checker - waivers anchored on CONTENT, not +line numbers - and is queued with @seedgo pending Patrick's GO. This is the +local reference, built the same way @drone built theirs. +""" + +import json +from pathlib import Path + +import pytest + +BRANCH_ROOT = Path(__file__).resolve().parents[1] +BYPASS_FILE = BRANCH_ROOT / ".seedgo" / "bypass.json" + +_ALL_RULES = json.loads(BYPASS_FILE.read_text(encoding="utf-8"))["bypass"] +LINE_SCOPED = [rule for rule in _ALL_RULES if rule.get("lines")] + + +def _rule_id(rule: dict) -> str: + return f"{rule['standard']}:{rule['file']}:{','.join(str(n) for n in rule['lines'])}" + + +def test_there_is_something_to_anchor(): + """Arming probe, and it must probe the list that is actually parametrized. + + pytest reports a parametrize over an EMPTY list as one skipped/deselected + entry, so a collector that silently finds nothing produces a green summary + for an instrument that checked nothing (@drone's surviving mutant, relayed + 2026-08-31). Asserting the RAW bypass list is non-empty is not enough - it + stays full of file-scoped rules while the line-scoped subset goes to zero. + """ + assert _ALL_RULES, f"no bypass rules parsed at all from {BYPASS_FILE}" + + # Recount from the RAW file rather than from the collector under judgement + # (@drone's cure, relayed by @seedgo). Asserting LINE_SCOPED is non-empty + # only catches a collector blinded ENTIRELY; a filter that quietly drops one + # rule leaves a non-empty list and every remaining anchor still passing, so + # the dropped waiver goes unwatched with a green board above it. + raw = json.loads(BYPASS_FILE.read_text(encoding="utf-8"))["bypass"] + expected = sum(1 for rule in raw if rule.get("lines")) + assert len(LINE_SCOPED) == expected, ( + f"the collector found {len(LINE_SCOPED)} line-scoped rules but the file " + f"holds {expected} - some waiver is being skipped, and its anchor is " + "not being checked by anything" + ) + + assert LINE_SCOPED, ( + "no line-scoped waivers found - if the last one was genuinely retired, " + "delete this file; if the parse broke, every anchor check below is " + "vacuously green and the drift this file exists to catch is unwatched" + ) + + +@pytest.mark.parametrize("rule", LINE_SCOPED, ids=_rule_id) +def test_every_line_scoped_waiver_declares_an_anchor(rule: dict): + """A waiver with no anchor is a pointer nothing can validate.""" + assert rule.get("anchor"), ( + f"{_rule_id(rule)} is line-scoped with no `anchor` - add the literal text " + "the waived line must contain, or the rule is unwatchable" + ) + + +@pytest.mark.parametrize("rule", LINE_SCOPED, ids=_rule_id) +def test_every_waived_line_still_contains_its_anchor(rule: dict): + """The pin: an edit that moves a waived line reds HERE, not weeks later.""" + target = BRANCH_ROOT / rule["file"] + assert target.exists(), f"{_rule_id(rule)} waives a file that no longer exists: {target}" + + lines = target.read_text(encoding="utf-8").splitlines() + anchor = rule["anchor"] + + for number in rule["lines"]: + assert 1 <= number <= len(lines), f"{_rule_id(rule)} points past the end of {rule['file']} ({len(lines)} lines)" + actual = lines[number - 1] + assert anchor in actual, ( + f"{_rule_id(rule)} has come unmoored. Line {number} of {rule['file']} " + f"no longer contains {anchor!r} - it reads {actual.strip()!r}.\n" + "The waiver is now suppressing the wrong line and NOT suppressing the " + "right one, silently. Re-derive it by A/B (remove the rule, re-run the " + "audit, match it to the one surviving violation) - never hand-adjust, " + "and delete it outright if the checker has since learned the exemption." + ) + + +def test_an_anchor_that_moved_is_actually_caught(): + """The negative control: prove the check can fail, on a synthetic rule. + + Without this, a bug in the loop above (a `for` that never iterates, an + anchor read off the wrong key) leaves every real rule passing for no reason. + This rule is synthetic on purpose - it cannot be fixed by editing the tree. + """ + drifted = { + "file": "apps/log_watcher_service.py", + "standard": "synthetic", + "lines": [1], + "anchor": "this text is not on line 1 of any file in this branch", + } + + with pytest.raises(AssertionError, match="come unmoored"): + test_every_waived_line_still_contains_its_anchor(drifted) diff --git a/src/aipass/trigger/tests/test_import_dead_cwd.py b/src/aipass/trigger/tests/test_import_dead_cwd.py new file mode 100644 index 000000000..535b048f3 --- /dev/null +++ b/src/aipass/trigger/tests/test_import_dead_cwd.py @@ -0,0 +1,915 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_import_dead_cwd.py - trigger imports without a readable cwd +# Date: 2026-08-31 +# Version: 1.3.0 +# Category: trigger/tests +# ============================================= + +"""Every trigger module must import without a readable working directory. + +THE MECHANISM, measured on the Windows CI gate 2026-08-31 (@memory's finding, +relayed round 4): ntpath.realpath calls os.getcwd() UNCONDITIONALLY - not only +for relative paths, the way posixpath does - and Path.resolve() routes through +it. So on Windows every Path(__file__).resolve() REACHED AT IMPORT is a +working-directory read, and a process whose cwd was deleted cannot import the +module at all. + +WHY THIS BRANCH, LOUDLY. @prax reproduced it on Linux against trigger and their +traceback is the reason this file exists: prax's logger imports +discovery/watcher.py, which imports aipass.trigger.apps.modules.core, which ran +trigger's handler guard, which died on line 12. One guard in one branch took +down every consumer of prax's logger. Trigger carried eight import-time sites +plus the guard. + +THE WORLD injects ntpath's behaviour as a CONDITION rather than a platform: +os.path.realpath is wrapped to read os.getcwd() first, then os.getcwd is denied. +The injection happens in a child process before any aipass import, so no module +has cached the real functions. In-process this property is unobservable - the +imports already happened - which is why every world here is a subprocess. + +AND THE INJECTION HAS TO REACH THE CALL. Patching os.path.realpath only reaches +sites that look the name up each time. CPython 3.10's pathlib captures a copy at +its own first import (_NormalAccessor.realpath), so on 3.10 the order of imports +decides whether this whole file measures anything - and 3.10 is in the CI matrix. +_ACCESSOR_PATCH rebinds the captured copy too, so the world arms on every +interpreter rather than on luck. Credit: @flow found it, @memory read the 3.10 +source, @devpulse relayed it. + +WHY THE PRELOAD LIST IS SHORT HERE. The fleet pattern preloads peer branches in +the healthy world so a pin measures its own branch only. Trigger cannot preload +@prax: prax's logger imports trigger, so preloading prax would import the very +sites under test in the healthy world and the pin would go green measuring +nothing. It is preloaded UNDER the denial instead, which is sound because prax's +own cure landed 2026-08-31 (verified: prax imports clean in this world). @cli was preloaded +healthy while its guard still walked inspect.stack(); that preload was retired +2026-08-31 once their cure was verified from here. +""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +# Peers held constant in the healthy world, before the denial. +# +# @cli's preload was here and is RETIRED. Two facts, and the second corrects +# what this file used to claim: their guard was cured 2026-08-31 and verified +# importing clean under the ntpath world below from this branch — AND @cli is +# never reached from these worlds at all. Measured: after importing every site +# below under the denial, no aipass.cli module is in sys.modules. prax is (it is +# on trigger's import path); prax does not pull cli. So the preload was +# belt-and-braces, not load-bearing, and this file previously said otherwise. +# +# @prax is deliberately NOT preloaded and never can be — prax's logger imports +# trigger, so preloading it would import the sites under test in the HEALTHY +# world and every assert here would go green measuring nothing. Prax rides under +# the denial instead, which is sound because their own cure landed the same day. +_PRELOAD = r""" +import rich.console # noqa: F401 +import inspect # noqa: F401 +import linecache # noqa: F401 +""" + +# THE CAPTURED COPY (@flow's find, relayed by @devpulse 2026-08-31). +# +# Patching os.path.realpath only reaches call sites that LOOK IT UP each time. +# CPython 3.10's pathlib does not: Lib/pathlib.py binds +# `realpath = staticmethod(os.path.realpath)` onto _NormalAccessor at class +# creation, i.e. at pathlib's FIRST import, and resolve() reads it back through +# `self._accessor.realpath`. Rebind os.path afterwards and you rebind a name +# nothing reads again - the world goes inert and every pin under it passes +# measuring nothing. +# +# So the world rebinds the captured copy too. staticmethod is not decoration: +# a plain function assigned to a class becomes a bound method and eats the path +# into `self`, which fails for the wrong reason and can leave a raise-shaped pin +# green. Guarded by hasattr because 3.11+ deleted _NormalAccessor - there the +# rebind is a no-op and the call-time lookup is what arms the world (measured on +# 3.12: this world arms whether pathlib is imported before or after the patch). +_ACCESSOR_PATCH = r""" +import pathlib + +_captured = getattr(pathlib, "_NormalAccessor", None) +if _captured is not None: + _captured.realpath = staticmethod(os.path.realpath) +""" + +# The posix-shaped denial: enough to reach every module-level resolve(). +_PREAMBLE = ( + _PRELOAD + + r""" +import os + +_real_realpath = os.path.realpath + + +def _ntpath_condition(path, **kw): + os.getcwd() # ntpath.realpath reads cwd before checking absoluteness + return _real_realpath(path, **kw) + + +os.path.realpath = _ntpath_condition + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd +""" + + _ACCESSOR_PATCH + + r""" +# Probe the instrument: does THIS interpreter's resolve() reach the denied call? +# The path is ABSOLUTE on purpose - a relative one dies in abspath for its own +# shape and would report ARMED whatever the patch did or failed to do. +try: + pathlib.Path(pathlib.__file__).resolve() + print("PROBE_VACUOUS") +except FileNotFoundError: + print("PROBE_ARMED") +""" +) + +# The ntpath-SHAPED denial, needed to reach the guard's old defect. +# +# On POSIX every route inspect.stack() takes to os.path.realpath runs through +# getabsfile(), whose os.path.abspath raises FileNotFoundError for the relative +# "" filenames an import stack carries - and +# getmodule() CATCHES FileNotFoundError, so the unguarded +# `modulesbyfile[os.path.realpath(f)]` below it is never reached. A pin built on +# the preamble above therefore goes GREEN against a reintroduced inspect.stack(): +# it measures the module-level resolve() next door, not the stack walk. +# +# ntpath has no such early raise, so on Windows getmodule() proceeds and dies. +# Emulated by giving abspath ntpath's non-raising behaviour while realpath keeps +# reading cwd - the injection then denies the call the DEFECT actually makes +# (@memory's rule), not one the platform happens to catch first. +_NTPATH_PREAMBLE = ( + _PRELOAD + + r""" +import os + +_real_realpath = os.path.realpath +_real_abspath = os.path.abspath + + +def _ntpath_realpath(path, **kw): + os.getcwd() # ntpath.realpath reads cwd before checking absoluteness + return _real_realpath(path, **kw) + + +def _ntpath_abspath(path): + # ntpath.abspath falls back rather than raising the way posixpath does. + try: + return _real_abspath(path) + except OSError: + return path + + +os.path.realpath = _ntpath_realpath +os.path.abspath = _ntpath_abspath + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd +""" + + _ACCESSOR_PATCH + + r""" +# Probe against the defect ITSELF, not a proxy: does inspect.stack() die in this +# world? If it does not, this pin proves nothing. +import inspect + +try: + inspect.stack() + print("STACK_SURVIVES") +except FileNotFoundError: + print("STACK_DIES") +""" +) + +# Every trigger site that resolves a path at IMPORT, named one per line so a +# failure says which module died rather than "the branch". +# +# events/plan_file.py was a fourth _find_repo_root caller and is gone: retired +# 2026-08-31 as measured inert (see apps/handlers/events/.archive/plan_file.py). +# This pin went red when it was archived, which is the list doing its job. +_SITES = r""" +import aipass.trigger.apps.handlers # noqa: F401 +print("GUARD_OK") +import aipass.trigger.apps.config # noqa: F401 +print("CONFIG_OK") +import aipass.trigger.apps.handlers.json.json_handler # noqa: F401 +print("JSON_OK") +import aipass.trigger.apps.handlers.escalation # noqa: F401 +print("ESCALATION_OK") +import aipass.trigger.apps.handlers.events.error_detected # noqa: F401 +print("ERROR_DETECTED_OK") +import aipass.trigger.apps.handlers.events.runaway_handler # noqa: F401 +print("RUNAWAY_OK") +import aipass.trigger.apps.modules.medic # noqa: F401 +print("MEDIC_OK") +import aipass.trigger.apps.modules.core # noqa: F401 +print("IMPORTED") +""" + +SWEEP_WORLD = _PREAMBLE + _SITES + +# The guard alone, under the world its old implementation actually died in. +NTPATH_GUARD_WORLD = ( + _NTPATH_PREAMBLE + + r""" +import aipass.trigger.apps.handlers # noqa: F401 + +print("IMPORTED") +""" +) + +# The whole branch under the ntpath world - the shape @prax's traceback took. +NTPATH_SWEEP_WORLD = _NTPATH_PREAMBLE + _SITES + + +def _run(world: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", world], + capture_output=True, + text=True, + timeout=120, + ) + + +def _assert_probe_armed(out: str) -> None: + """The instrument must be able to fire, or the pin proves nothing. + + This used to accept PROBE_VACUOUS on sys.version_info < (3, 11), citing + "pre-3.11 pathlib never routes an absolute resolve through os.path.realpath". + That sentence was a RETRACTED diagnosis and it is false: 3.10 routes through + a copy of os.path.realpath captured onto _NormalAccessor at pathlib's first + import. The clause therefore blessed exactly the vacuity it exists to catch, + on the one interpreter where the world could genuinely go inert - and 3.10 is + in the CI matrix. _ACCESSOR_PATCH arms that interpreter, so no version is + excused any more. A vacuous world is a failure everywhere. + """ + assert "PROBE_ARMED" in out, ( + "the denial did not reach resolve() - the instrument is broken, not the " + f"world, and every pin under it proves nothing:\n{out}" + ) + + +# The JUDGEMENT, separated from the WORLD (@commons' round-5 rule). No +# interpreter here can produce a vacuous probe, so the branch that decides what +# to DO about one is unreachable from any real world on this host - which is how +# the version escape hatch survived in the first place, and how it would come +# back. Fed synthetic strings, every interpreter case is reachable from any +# machine, including the one no local run can create. + + +def test_a_vacuous_probe_is_refused_on_the_interpreter_that_could_produce_one(monkeypatch): + """3.10 is the ONE interpreter that can go inert, and it gets no exemption. + + The deleted clause read `assert sys.version_info < (3, 11)`, so on 3.10 it + returned quietly. This is the pin that convicts its return: faking the + version is the only way this host can stand where 3.10 stands. + """ + monkeypatch.setattr(sys, "version_info", (3, 10, 4, "final", 0)) + + with pytest.raises(AssertionError): + _assert_probe_armed("PROBE_VACUOUS\nIMPORTED\n") + + +def test_a_vacuous_probe_is_refused_on_this_interpreter_too(): + """The unfaked half, so the pin above cannot pass on the monkeypatch alone.""" + with pytest.raises(AssertionError): + _assert_probe_armed("PROBE_VACUOUS\nIMPORTED\n") + + +def test_an_armed_probe_is_accepted(): + """The judgement is not simply 'always raise' - it has to let a real world through.""" + _assert_probe_armed("PROBE_ARMED\nIMPORTED\n") + + +def test_a_probe_that_printed_nothing_is_refused(): + """Silence is not consent: a world that crashed before probing proves nothing.""" + with pytest.raises(AssertionError): + _assert_probe_armed("IMPORTED\n") + + +def test_import_time_sites_survive_a_denied_cwd(): + """The eight import-time resolve sites, under a denied working directory.""" + result = _run(SWEEP_WORLD) + out = result.stdout + + assert "IMPORTED" in out, f"a trigger import died under the dead-cwd world:\nstdout={out}\nstderr={result.stderr}" + _assert_probe_armed(out) + + +def test_handlers_guard_survives_the_ntpath_shaped_denial(): + """The guard must not walk inspect.stack(). + + Separate from the sweep because the posix-shaped world is satisfied by the + guarded resolve alone. This one is RED against an inspect.stack() walk and + green against the sys._getframe cure. + """ + result = _run(NTPATH_GUARD_WORLD) + out = result.stdout + + assert "STACK_DIES" in out, ( + "inspect.stack() survived the ntpath-shaped denial - the instrument no " + f"longer reaches the defect and this pin is vacuous:\n{out}" + ) + assert "IMPORTED" in out, ( + f"the handlers guard still depends on a readable cwd:\nstdout={out}\nstderr={result.stderr}" + ) + + +def test_whole_branch_imports_under_the_ntpath_shaped_denial(): + """@prax's traceback, as a pin: importing trigger must not need a cwd.""" + result = _run(NTPATH_SWEEP_WORLD) + out = result.stdout + + assert "STACK_DIES" in out, f"the ntpath world went vacuous - this pin proves nothing:\n{out}" + assert "IMPORTED" in out, f"a trigger import died under the ntpath world:\nstdout={out}\nstderr={result.stderr}" + + +# --------------------------------------------------------------------------- +# Is _ACCESSOR_PATCH load-bearing, or decoration? Falsifiable HERE, on 3.12. +# +# The interpreter that needs it (3.10) is in the CI matrix and is not installed +# on this machine, so "it works on 3.10" is not something this suite can claim. +# What it CAN do is rebuild 3.10's capture shape - an eager staticmethod bound at +# class creation, read back through an instance - and pin the two directions +# separately: the bare os.path patch is INERT against it, the shipped patch ARMS +# it. +# +# ROUND 7, PAID FOR ON THE WINDOWS RUNNER. The first cut let the shape capture +# the LIVE os.path.realpath and then asked "did a later patch reach it?" by +# reading raise/no-raise under a denied getcwd. That discriminates on POSIX only: +# posixpath.realpath ignores the cwd for an absolute path, so RAISED could only +# mean the patch landed. On nt, ntpath.realpath (ntpath.py:673) computes +# `cwd = os.getcwd()` UNCONDITIONALLY - above the `isabs` test that is the only +# thing reading it - so the ORIGINAL raises too and RAISED stops discriminating. +# CI printed EMULATION_EAGER / CAPTURE_ARMED and the pin failed for the reason it +# was built to detect, one platform over. +# +# @memory's verdict, and the rule this file now obeys: AN INSTRUMENT MUST NOT +# IMPORT BEHAVIOUR IT IS NOT TESTING. The captured function is a SENTINEL that +# touches no filesystem, no cwd and no path module, and answers with a constant +# rather than a path - so whatever raises or returns afterwards is the patch's +# doing, on any platform, and no return-value pin can quietly start measuring the +# host (@drone's addendum). +# --------------------------------------------------------------------------- + +# Two distinguishable sentinels, because a sentinel alone cannot show EAGERNESS. +# @skills' asymmetry note: sentinel the dialect-divergent half only, or a pin +# that measures when the capture happened goes dark. An identity check would not +# have survived the cure either - a lazy wrapper around one sentinel returns the +# same answer as an eager capture of it. Moving the SOURCE after class creation +# is what separates them, and it needs no platform behaviour at all. +_SENTINELS = r""" +def _sentinel_captured(path, *, strict=False): + # Touches no filesystem, no cwd, no path module. Answers with a CONSTANT, + # not a path, so no return-value check can start measuring the host. + return "CAPTURED" + + +def _sentinel_moved(path, *, strict=False): + return "MOVED" + + +_source_realpath = _sentinel_captured + +# The control FOR the eagerness control (@flow's shape, 2026-08-31). The +# discriminator below is "did the answer follow the moved source name" - and it +# can only discriminate while the two sentinels give DIFFERENT answers. Collapse +# them to one string and the eagerness pin reports EAGER forever with nothing +# saying so; measured as a surviving mutant here before this line existed. +print("SENTINELS_DIFFER", _sentinel_captured(_ABS) != _sentinel_moved(_ABS)) +""" + +# The win32 branch of ntpath.realpath, built BY NAME from ntpath's own helpers. +# +# NOT by aliasing: off Windows `ntpath.realpath` is a WRAPPER that returns +# abspath(path) (ntpath.py:564), not an alias - `ntpath.realpath is +# ntpath.abspath` is False here, so an is-test proves nothing either way +# (@flow's alias trap as corrected by @skills). Aliasing it would produce a +# green-looking answer that silently stops reproducing the bug. +_NT_SHAPED = r""" +import ntpath + +_getcwd_reads = [] + + +def _nt_shaped_realpath(path, *, strict=False): + # ntpath.py:659-673, str branch: cwd is computed BEFORE the isabs test that + # is the only thing that consumes it. + path = ntpath.normpath(path) + _getcwd_reads.append(1) + cwd = os.getcwd() + if not ntpath.isabs(path): + path = ntpath.join(cwd, path) + return path +""" + +_CAPTURE_HEAD = r""" +import ntpath +import os +import posixpath +import sys + +# ABSOLUTE on purpose - a relative path dies in abspath for its own shape and +# would report ARMED whatever the patch did or failed to do - and absolute IN +# BOTH DIALECTS, which is the round-8 correction. +# +# This was sys.executable, which is absolute in the HOST's dialect and nobody +# else's. On the Windows runner that is D:\a\...\python.exe, and +# posixpath.isabs() of it is FALSE - so the posix capture treated it as relative, +# joined the denied cwd, and reported ARMED where the row demanded INERT. The +# arming row had silently keyed on here-equals-a-posix-host. +# +# A rooted literal is absolute to posixpath AND to ntpath (drive-relative there, +# which costs nothing since nothing resolves it), and exists on no machine, so +# no filesystem can answer for the probe either. @spawn's sentence: the +# platform-shaped assumption moves up a level each time it is cured - round 6 +# the world, round 7 the emulation, round 8 the thing that INSTALLS the probe. +_ABS = "/AIPASS_NO_SUCH_PROBE_PATH" +""" + +# Said out loud rather than assumed, and printed before any verdict: a row whose +# probe is not absolute in the captured dialect is measuring the host's notion of +# absoluteness, not the patch. +# +# posixpath ONLY, and that is a measurement rather than an oversight. The two +# dialects are asymmetric (@flow's note, confirmed here): posixpath REFUSES an nt +# literal, ntpath ACCEPTS a posix one and treats it as drive-relative. So there +# is no path that is posix-absolute and not nt-absolute - +# '/x' posix True nt True +# 'D:\x' posix False nt True +# r'\\srv\s' posix False nt True +# - and an `and ntpath.isabs(_ABS)` clause can never fire. It was here and a +# mutant proved it: dropping it changed nothing. Named rather than kept looking +# load-bearing. posixpath is the strict dialect, so it is the one that has to +# agree, and every row shares one literal. +_PROBE_CONTROL = r""" +print("PROBE_IS_ABS", posixpath.isabs(_ABS)) +""" + +# @spawn's ROUTE_ARMED/ROUTE_DARK: the emulation proves it TOOK the route before +# anything downstream claims anything. For the nt shape that means showing the +# unconditional cwd read really fires for an ABSOLUTE path - which is the entire +# reason the nt dialect breaks a raise-shaped probe. +_ROUTE_CONTROL = r""" +if _CAPTURED is _nt_shaped_realpath or _HOST_REALPATH is _nt_shaped_realpath: + _before = len(_getcwd_reads) + _nt_shaped_realpath(_ABS) + print("ROUTE_ARMED" if len(_getcwd_reads) > _before else "ROUTE_DARK") +""" + +_BUILD_ACCESSOR = r""" +class _NormalAccessorShape: + # EAGER, at class creation - exactly when pathlib 3.10 binds its own copy. + realpath = staticmethod(_CAPTURED) + + +_accessor = _NormalAccessorShape() +""" + +# The eagerness control, by VALUE and with no platform behaviour in it: move the +# source name after the class was built. An eager capture still answers with the +# function it captured; a lazy wrapper follows the name and answers "MOVED". +_EAGER_CONTROL = r""" +if _CAPTURED is _sentinel_captured: + _source_realpath = _sentinel_moved + print("EMULATION_EAGER" if _accessor.realpath(_ABS) == "CAPTURED" else "EMULATION_NOT_EAGER") +else: + print("EMULATION_EAGER" if _NormalAccessorShape.realpath is _CAPTURED else "EMULATION_NOT_EAGER") +""" + +_DENIAL_HEAD = r""" +_real_realpath = _HOST_REALPATH + + +def _denied(path, **kw): + os.getcwd() + return _real_realpath(path, **kw) + + +os.path.realpath = _denied + + +def _dead_getcwd(): + raise FileNotFoundError(2, "cwd deleted", "") + + +os.getcwd = _dead_getcwd +""" + +# The shipped fragment, aimed at the emulated holder rather than pathlib's. +_APPLY_SHIPPED_PATCH = r""" +_captured = _NormalAccessorShape +if _captured is not None: + _captured.realpath = staticmethod(os.path.realpath) +""" + +# Read back through the INSTANCE, the way pathlib 3.10's resolve() does +# (`self._accessor.realpath`), never off the class. +_EXERCISE_CAPTURE = r""" +try: + result = _accessor.realpath(_ABS) +except FileNotFoundError: + print("CAPTURE_ARMED") +except TypeError as exc: + print("PLAIN_EATS_SELF", type(exc).__name__) +else: + # A return-value check, not a survived/raised one: a plain-function rebind + # eats the path into `self` and can otherwise look like a pass. + print("CAPTURE_INERT", result) +""" + +_CAPTURE_EXPR = { + "sentinel": "_sentinel_captured", + "posix": "posixpath.realpath", + "nt": "_nt_shaped_realpath", +} + +_HOST_EXPR = {"posix": "posixpath.realpath", "nt": "_nt_shaped_realpath"} + + +def _capture_world( + capture: str, + *, + host: str = "posix", + apply_patch: bool = False, + bare_rebind: bool = False, + probe: str | None = None, + spoof_executable: str | None = None, +) -> str: + """Assemble one capture world. + + capture= what the accessor captured EAGERLY. "sentinel" is the shipped + shape; "posix"/"nt" rebuild the PRE-CURE shape so the round-7 failure stays + reproducible on this host rather than living only in a CI log. + host= which dialect the SURROUNDING os.path.realpath speaks. Varying this + while holding the capture fixed is the litmus. + """ + world = "" + if spoof_executable is not None: + # Stand where the other platform stands, before the world computes + # anything from it. + world += f"import sys\n\nsys.executable = {spoof_executable!r}\n" + world += _CAPTURE_HEAD + if probe is not None: + world += f"_ABS = {probe!r}\n" + world += _PROBE_CONTROL + _SENTINELS + _NT_SHAPED + world += f"\n_CAPTURED = {_CAPTURE_EXPR[capture]}\n" + world += f"_HOST_REALPATH = {_HOST_EXPR[host]}\n" + world += _ROUTE_CONTROL + _BUILD_ACCESSOR + _EAGER_CONTROL + _DENIAL_HEAD + if apply_patch: + world += _APPLY_SHIPPED_PATCH + if bare_rebind: + world += "\n_NormalAccessorShape.realpath = os.path.realpath # NO staticmethod\n" + return world + _EXERCISE_CAPTURE + + +def _assert_emulation_sound(out: str, capture: str, host: str = "posix") -> None: + """Nothing downstream may be believed until the emulation says it is real.""" + assert "SENTINELS_DIFFER True" in out, ( + "the two sentinels answer alike, so the eagerness discriminator below " + f"cannot report anything but EAGER - it is dark, not passing:\n{out}" + ) + assert "PROBE_IS_ABS True" in out, ( + "the probe path is not absolute to posixpath, the strict dialect, so this row is " + f"measuring the host's notion of absoluteness rather than the patch:\n{out}" + ) + assert "EMULATION_EAGER" in out, f"the emulation is not an eager capture - it describes another shape:\n{out}" + if "nt" in (capture, host): + assert "ROUTE_ARMED" in out, ( + "the nt-shaped realpath did not read the cwd for an ABSOLUTE path, so " + f"this world is not the one that broke on the Windows runner:\n{out}" + ) + + +def test_the_captured_accessor_is_inert_under_a_bare_os_path_patch(): + """3.10's defect, rebuilt: patching os.path.realpath does not reach it.""" + result = _run(_capture_world("sentinel")) + out = result.stdout + + _assert_emulation_sound(out, "sentinel") + assert "CAPTURE_INERT CAPTURED" in out, ( + "the bare os.path patch reached the captured copy, so there is nothing " + f"for _ACCESSOR_PATCH to fix and this file is carrying dead weight:\n{out}" + ) + + +def test_the_shipped_accessor_patch_arms_the_captured_copy(): + """_ACCESSOR_PATCH is load-bearing: remove it and this pin goes red.""" + result = _run(_capture_world("sentinel", apply_patch=True)) + out = result.stdout + + _assert_emulation_sound(out, "sentinel") + assert "CAPTURE_ARMED" in out, ( + "the shipped accessor patch did not arm the captured copy - on 3.10 the " + f"dead-cwd worlds are inert and every pin under them is vacuous:\n{out}" + ) + + +def test_the_accessor_patch_needs_staticmethod_not_a_bare_function(): + """Why _ACCESSOR_PATCH wraps: bare, the instance is eaten into the path slot. + + Without this, someone simplifies the fragment to a plain assignment, the + call fails with TypeError instead of FileNotFoundError, and a pin that only + asks "did it raise" reports a cured world. + """ + result = _run(_capture_world("sentinel", bare_rebind=True)) + out = result.stdout + + _assert_emulation_sound(out, "sentinel") + assert "PLAIN_EATS_SELF" in out, ( + "a bare function rebind did NOT bind as a method here - the staticmethod " + f"wrapper in _ACCESSOR_PATCH may no longer be doing anything:\n{out}" + ) + assert "CAPTURE_ARMED" not in out, f"the bare rebind looked like a working denial, which is the trap:\n{out}" + + +@pytest.mark.parametrize("host", ["posix", "nt"]) +def test_the_inert_verdict_does_not_move_between_host_dialects(host: str): + """@memory's litmus: run the probe under the opposite platform's dialect and + require the verdict NOT to move. + + The capture is held fixed at the sentinel and the SURROUNDING realpath is + swapped, which is the variable that actually moved between the Linux and + Windows runners. This is the round-7 regression pin: revert the sentinel to + a live capture and the nt row goes red HERE, on Linux, instead of on the + Windows runner three hours later. + """ + result = _run(_capture_world("sentinel", host=host)) + out = result.stdout + + _assert_emulation_sound(out, "sentinel", host) + assert "CAPTURE_INERT CAPTURED" in out, f"the sentinel verdict moved under the {host} host dialect:\n{out}" + + +@pytest.mark.parametrize( + ("dialect", "expected"), + [("posix", "CAPTURE_INERT"), ("nt", "CAPTURE_ARMED")], +) +def test_the_litmus_is_armed_by_a_shape_whose_verdict_does_move(dialect: str, expected: str): + """The arming for the litmus above (@skills' trap (d)). + + A run-under-both-dialects check is vacuously green if the dialect slot + reaches nothing - and with a sentinel it reaches nothing BY DESIGN. So one + shape whose verdict is SUPPOSED to move has to be measured beside it: the + PRE-CURE live capture, which reads INERT on posix and ARMED on nt for two + different reasons. That divergence IS the round-7 failure, reproduced on + this host rather than quoted from a CI log. + """ + result = _run(_capture_world(dialect, host=dialect)) + out = result.stdout + + _assert_emulation_sound(out, dialect, dialect) + assert expected in out, ( + f"the pre-cure {dialect} capture no longer reads {expected} - the failure " + f"this file was rebuilt around has stopped reproducing:\n{out}" + ) + + +# A stand-in for each host's sys.executable, which is what the probe path used to +# be. Absolute to its own dialect and to nothing else - which is the entire bug. +_HOST_SHAPED_EXECUTABLE = { + "posix": "/usr/bin/python3", + "nt": "D:\\a\\AIPass\\AIPass\\.venv\\Scripts\\python.exe", +} + + +@pytest.mark.parametrize("host_shape", ["posix", "nt"]) +def test_a_host_shaped_probe_path_is_refused_rather_than_silently_answered(host_shape: str): + """The round-8 regression pin, and it convicts the INSTALLER not the world. + + Round 7's probe path was sys.executable: absolute in the running host's + dialect and nobody else's. On the Windows runner posixpath.isabs() of it is + False, so the posix capture treated it as relative, joined the denied cwd, + and reported ARMED - and the row announced "the failure this file was rebuilt + around has stopped reproducing", which was a statement about the probe + wearing the words of a statement about the subject. + + So the world now REFUSES a probe that is not absolute in both dialects and + says which thing is wrong, rather than answering a question it can no longer + read (@spawn's UNAVAILABLE-with-a-reason). This pin runs both host shapes on + this machine, so neither leg of the matrix has to discover it. + """ + result = _run(_capture_world("posix", probe=_HOST_SHAPED_EXECUTABLE[host_shape])) + out = result.stdout + + if host_shape == "posix": + # Absolute to posixpath here, so the row can still answer - and must. + assert "PROBE_IS_ABS True" in out, out + assert "CAPTURE_INERT" in out, f"a posix-absolute probe stopped being inert against the posix capture:\n{out}" + return + + assert "PROBE_IS_ABS False" in out, ( + "an nt-shaped executable path was accepted as absolute - " + f"posixpath.isabs() must reject it, or round 8 can happen again:\n{out}" + ) + with pytest.raises(AssertionError, match="not absolute to posixpath"): + _assert_emulation_sound(out, "posix") + + +@pytest.mark.parametrize("host_shape", ["posix", "nt"]) +def test_the_probe_path_does_not_follow_the_hosts_executable(host_shape: str): + """Convicts the round-8 defect ITSELF on this machine, not just its symptom. + + The pin above proves the world refuses a bad probe when one is handed to it. + This one proves the world does not GO AND FETCH a bad one: sys.executable is + spoofed to each platform's shape before the world computes _ABS, and the + verdict must not move. A literal is immune; anything derived from the host - + sys.executable, os.getcwd(), __file__ - is convicted here rather than on the + runner that happens to have the other shape. + """ + result = _run(_capture_world("posix", spoof_executable=_HOST_SHAPED_EXECUTABLE[host_shape])) + out = result.stdout + + _assert_emulation_sound(out, "posix") + assert "CAPTURE_INERT" in out, ( + f"the posix arming row moved when sys.executable took its {host_shape} shape - " + f"the probe path is following the host again:\n{out}" + ) + + +def test_the_shipped_probe_path_is_absolute_to_the_strict_dialect(): + """The positive half: the literal actually shipped satisfies the rule. + + Without this the pin above passes on a world that refuses EVERYTHING, which + would be a refusal wearing a control's name. + """ + result = _run(_capture_world("posix")) + + assert "PROBE_IS_ABS True" in result.stdout, result.stdout + _assert_emulation_sound(result.stdout, "posix") + + +def test_repo_root_fallback_is_the_source_tree_never_the_process_directory(): + """The QUIET defect: four walks used to end in `return Path.cwd()`. + + A registry-less tree (every clean clone; AIPASS_REGISTRY.json is gitignored) + took that fallback on every import, so each caller resolved against whatever + directory the shell happened to be in. Pinned from a child process standing + somewhere unrelated, because in-process the cwd is the repo root and the + wrong answer and the right one look identical. + """ + world = r""" +import os +import tempfile +from pathlib import Path + +os.chdir(tempfile.gettempdir()) + +from aipass.trigger.apps.handlers.repo_root import SOURCE_ROOT, find_repo_root + +# A marker that exists nowhere above this file forces the fallback. +answer = find_repo_root(marker="AIPASS_NO_SUCH_MARKER.json", caller="pin") +print("ANSWER", answer) +print("SOURCE_ROOT", SOURCE_ROOT) +print("CWD", Path.cwd()) +""" + result = _run(world) + lines = dict(line.split(" ", 1) for line in result.stdout.strip().splitlines() if " " in line) + + assert lines.get("ANSWER") == lines.get("SOURCE_ROOT"), ( + f"the fallback is no longer the source tree:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + assert lines.get("ANSWER") != lines.get("CWD"), ( + "the child stood in the repo root, so this run could not tell the source " + f"tree from the process directory - the pin measured nothing:\n{result.stdout}" + ) + + +def test_repo_root_refuses_a_case_folded_marker(tmp_path, monkeypatch): + """A cased literal folds too: exists() answers about `aipass_registry.json`. + + find_repo_root decides which installation this branch belongs to and several + callers build write paths from the answer, so a folded bait file accepted as + THE repo root is the quiet defect arriving through a different door. + + THE FOLDING IS INJECTED, not waited for. On ext4 the bait cannot fire, so a + test that merely places a lowercase file and asserts refusal passes for the + wrong reason - measured: a mutant degrading exists_exactly() to a bare + exists() SURVIVED that shape on Linux. Path.exists is given the answer a + folding filesystem would give, which is the call the defect actually makes. + """ + from aipass.trigger.apps.handlers import repo_root + + bait_dir = tmp_path / "bait" + bait_dir.mkdir() + (bait_dir / "aipass_registry.json").write_text("{}", encoding="utf-8") + start = bait_dir / "deep" + start.mkdir() + + real_exists = Path.exists + + def folding_exists(self, *args, **kwargs): + if real_exists(self, *args, **kwargs): + return True + try: + names = {entry.name.lower() for entry in self.parent.iterdir()} + except OSError: + return False + return self.name.lower() in names + + monkeypatch.setattr(Path, "exists", folding_exists) + + # The instrument must be live, or this pin proves nothing. + assert (bait_dir / "AIPASS_REGISTRY.json").exists(), "the folding injection never fired" + + assert not repo_root.exists_exactly(bait_dir / "AIPASS_REGISTRY.json"), ( + "a case-folded bait file was accepted as the blessed filename" + ) + assert repo_root.find_repo_root(start, caller="pin") != bait_dir, ( + "the walk stopped at a directory holding aipass_registry.json" + ) + + # And the exact spelling is still accepted, under the same injected world. + real_dir = tmp_path / "real" + real_dir.mkdir() + (real_dir / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") + assert repo_root.exists_exactly(real_dir / "AIPASS_REGISTRY.json") + assert repo_root.find_repo_root(real_dir, caller="pin") == real_dir + + +def test_the_folding_injection_is_a_real_negative_control(tmp_path, monkeypatch): + """The control FOR the control: without the injection, the bait is inert. + + Proves the previous test's refusal comes from exists_exactly and not from + ext4 answering no - a green that would survive deleting the cure. + """ + from aipass.trigger.apps.handlers import repo_root + + bait_dir = tmp_path / "bait" + bait_dir.mkdir() + (bait_dir / "aipass_registry.json").write_text("{}", encoding="utf-8") + + if (bait_dir / "AIPASS_REGISTRY.json").exists(): + # A genuinely folding host: the injection is redundant there, and the + # pin above measures the live condition rather than an emulation. + assert not repo_root.exists_exactly(bait_dir / "AIPASS_REGISTRY.json") + else: + # Case-sensitive host: the bait is invisible WITHOUT the injection, + # which is exactly why the injection exists. + assert not repo_root.exists_exactly(bait_dir / "AIPASS_REGISTRY.json") + + +# =========================================================================== +# The deleted second stack walk — reachable only OUTSIDE an import +# =========================================================================== +# +# @cli's finding, 2026-08-31, and it landed on this branch too: the guard's +# `caller_file is None` branch is UNREACHABLE from any import-shaped pin. During +# a real import apps/__init__.py does `from . import handlers`, so there is +# always a real-file frame above the guard and _find_real_caller never returns +# None. MEASURED HERE: restoring the deleted inspect.stack() walk as a mutant +# passed all 1058 trigger tests, including every world above. Five green pins and +# the defect back in the tree. +# +# The branch is reachable exactly where it was meant to be — a REPL or a -c +# script, the callers it exists to allow — so the world calls the guard directly +# instead of importing something. + +NO_CALLER_WORLD = ( + _NTPATH_PREAMBLE + + r""" +from aipass.trigger.apps.handlers import _find_real_caller, _guard_branch_access + +# Positive control: this world must actually REACH the branch under test. +# A -c frame is spelled "", which the walk skips, so there is no +# caller outside this file and the guard takes its None arm. +caller, line = _find_real_caller() +if caller is None: + print("NO_CALLER_REACHED") +else: + print("NO_CALLER_NOT_REACHED", caller) + +_guard_branch_access() +print("IMPORTED") +""" +) + + +def test_the_no_caller_branch_needs_no_cwd(): + """The guard's `caller_file is None` arm, under the ntpath-shaped denial. + + Red against a restored second inspect.stack() walk, which no import world + can reach. Credited to @cli, who found it by mutating their own identical + deletion and watching it stroll past five green pins. + """ + result = _run(NO_CALLER_WORLD) + out = result.stdout + + assert "STACK_DIES" in out, f"the ntpath world went vacuous - this pin proves nothing:\n{out}" + assert "NO_CALLER_REACHED" in out, ( + f"the guard found a caller, so its None arm never ran and this pin measured nothing:\n{out}" + ) + assert "IMPORTED" in out, ( + f"the guard's no-caller arm still depends on a readable cwd:\nstdout={out}\nstderr={result.stderr}" + ) diff --git a/src/aipass/trigger/tests/test_log_watcher_service.py b/src/aipass/trigger/tests/test_log_watcher_service.py index 2f08c5c58..0734c78bc 100644 --- a/src/aipass/trigger/tests/test_log_watcher_service.py +++ b/src/aipass/trigger/tests/test_log_watcher_service.py @@ -106,7 +106,8 @@ def test_both_watchers_start(self, monkeypatch: pytest.MonkeyPatch) -> None: mod.start_system_watcher = MagicMock(return_value=True) captured: list[str] = [] - monkeypatch.setattr("builtins.print", lambda *a, **kw: captured.append(str(a))) + monkeypatch.setattr(mod.logger, "info", lambda *a, **kw: captured.append(str(a))) + monkeypatch.setattr(mod.logger, "error", lambda *a, **kw: captured.append(str(a))) mod.main() @@ -127,7 +128,8 @@ def test_only_branch_watcher_starts(self, monkeypatch: pytest.MonkeyPatch) -> No mod.start_system_watcher = MagicMock(return_value=None) captured: list[str] = [] - monkeypatch.setattr("builtins.print", lambda *a, **kw: captured.append(str(a))) + monkeypatch.setattr(mod.logger, "info", lambda *a, **kw: captured.append(str(a))) + monkeypatch.setattr(mod.logger, "error", lambda *a, **kw: captured.append(str(a))) mod.main() @@ -147,7 +149,8 @@ def test_only_system_watcher_starts(self, monkeypatch: pytest.MonkeyPatch) -> No mod.start_system_watcher = MagicMock(return_value=True) captured: list[str] = [] - monkeypatch.setattr("builtins.print", lambda *a, **kw: captured.append(str(a))) + monkeypatch.setattr(mod.logger, "info", lambda *a, **kw: captured.append(str(a))) + monkeypatch.setattr(mod.logger, "error", lambda *a, **kw: captured.append(str(a))) mod.main() @@ -272,8 +275,15 @@ def capture_signal(signum: int, handler: object) -> object: assert signal.SIGTERM in installed_signals assert signal.SIGINT in installed_signals - def test_both_fail_prints_stderr(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: - """When both watchers fail, error message goes to stderr.""" + def test_both_fail_is_reported_at_error_level(self, monkeypatch: pytest.MonkeyPatch) -> None: + """When both watchers fail, the reason is reported at ERROR level. + + This used to assert on stderr. The five lifecycle print() calls became + prax logger calls on 2026-08-31 (unstructured output invisible to the + monitoring surface this process feeds), so the claim moved with the + transport — and the LEVEL is now part of it, which stderr could not + express: a failed startup is the one message here that is not INFO. + """ mod = _import_module() pre_set = threading.Event() @@ -283,11 +293,20 @@ def test_both_fail_prints_stderr(self, monkeypatch: pytest.MonkeyPatch, capsys: mod.start_branch_watcher = MagicMock(return_value=False) mod.start_system_watcher = MagicMock(return_value=False) + errors: list[str] = [] + infos: list[str] = [] + monkeypatch.setattr(mod.logger, "error", lambda *a, **kw: errors.append(str(a))) + monkeypatch.setattr(mod.logger, "info", lambda *a, **kw: infos.append(str(a))) + with pytest.raises(SystemExit): mod.main() - captured = capsys.readouterr() - assert "Both watchers failed" in captured.err + assert any("Both watchers failed" in e for e in errors), ( + f"the startup failure was not reported at ERROR level: errors={errors} infos={infos}" + ) + assert not any("Both watchers failed" in i for i in infos), ( + "the startup failure was downgraded to INFO, where the log watchers do not see it" + ) class TestReloadExit: @@ -328,7 +347,8 @@ def test_an_ordinary_shutdown_does_not_exit_non_zero(self, monkeypatch: pytest.M mod.start_branch_watcher = MagicMock(return_value=True) mod.start_system_watcher = MagicMock(return_value=True) captured: list[str] = [] - monkeypatch.setattr("builtins.print", lambda *a, **kw: captured.append(str(a))) + monkeypatch.setattr(mod.logger, "info", lambda *a, **kw: captured.append(str(a))) + monkeypatch.setattr(mod.logger, "error", lambda *a, **kw: captured.append(str(a))) mod.main() # must not raise SystemExit diff --git a/src/aipass/trigger/tests/test_plan_file_handler.py b/src/aipass/trigger/tests/test_plan_file_handler.py deleted file mode 100644 index 623ff35dc..000000000 --- a/src/aipass/trigger/tests/test_plan_file_handler.py +++ /dev/null @@ -1,374 +0,0 @@ -# =================== AIPass ==================== -# Name: test_plan_file_handler.py -# Description: Tests for plan_file event handlers (created, deleted, moved) -# Version: 1.0.0 -# Created: 2026-04-25 -# Modified: 2026-04-25 -# ============================================= - -"""Tests for plan_file event handlers.""" - -import pytest -from unittest.mock import MagicMock -from pathlib import Path - - -@pytest.fixture(autouse=True) -def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Mock heavy infrastructure imports before importing the handler module.""" - import sys - - from aipass.trigger.apps.config import atomic_write_json - - mock_config = MagicMock() - mock_config.TRIGGER_ROOT = tmp_path - mock_config.AIPASS_PKG_ROOT = tmp_path / "aipass" - mock_config.atomic_write_json = atomic_write_json - monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config) - - mock_json_handler = MagicMock() - mock_json_handler.log_operation = MagicMock(return_value=True) - json_pkg = MagicMock() - json_pkg.json_handler = mock_json_handler - monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg) - monkeypatch.setitem( - sys.modules, - "aipass.trigger.apps.handlers.json.json_handler", - mock_json_handler, - ) - - monkeypatch.delitem( - sys.modules, - "aipass.trigger.apps.handlers.events.plan_file", - raising=False, - ) - - -def _import_plan_file(): - """Import fresh after mocking.""" - import aipass.trigger.apps.handlers.events.plan_file as m - - return m - - -class TestHandlePlanFileCreated: - """Tests for handle_plan_file_created.""" - - def test_new_plan_added_to_registry(self, tmp_path: Path) -> None: - """New FPLAN file adds entry with status open.""" - mod = _import_plan_file() - mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1}) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - plan_path = str(tmp_path / "FPLAN-0042.md") - mod.handle_plan_file_created(path=plan_path) - - mod._save_registry.assert_called_once() # type: ignore[union-attr] - saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr] - assert "0042" in saved["plans"] - plan = saved["plans"]["0042"] - assert plan["status"] == "open" - assert plan["file_path"] == plan_path - assert plan["subject"] == "Auto-detected PLAN" - - def test_existing_open_plan_is_noop(self, tmp_path: Path) -> None: - """Existing open plan causes early return without saving.""" - mod = _import_plan_file() - mod._load_registry = MagicMock( - return_value={ - "plans": {"0042": {"status": "open", "file_path": "/old/FPLAN-0042.md"}}, - "next_number": 43, - } - ) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - plan_path = str(tmp_path / "FPLAN-0042.md") - mod.handle_plan_file_created(path=plan_path) - - mod._save_registry.assert_not_called() # type: ignore[union-attr] - - def test_existing_closed_plan_updates_location(self, tmp_path: Path) -> None: - """Closed plan gets location fields updated, status preserved.""" - mod = _import_plan_file() - mod._load_registry = MagicMock( - return_value={ - "plans": { - "0042": { - "status": "closed", - "file_path": "/old/FPLAN-0042.md", - "closed_reason": "completed", - } - }, - "next_number": 43, - } - ) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - plan_path = str(tmp_path / "FPLAN-0042.md") - mod.handle_plan_file_created(path=plan_path) - - mod._save_registry.assert_called_once() # type: ignore[union-attr] - saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr] - plan = saved["plans"]["0042"] - assert plan["status"] == "closed" - assert plan["file_path"] == plan_path - assert plan["closed_reason"] == "completed" - assert "last_updated" in plan - - def test_updates_next_number_when_needed(self, tmp_path: Path) -> None: - """Next number advances past the new plan number.""" - mod = _import_plan_file() - mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1}) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - plan_path = str(tmp_path / "FPLAN-0042.md") - mod.handle_plan_file_created(path=plan_path) - - saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr] - assert saved["next_number"] == 43 - - def test_does_not_lower_next_number(self, tmp_path: Path) -> None: - """Next number stays at 100 when plan 42 is added.""" - mod = _import_plan_file() - mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 100}) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - plan_path = str(tmp_path / "FPLAN-0042.md") - mod.handle_plan_file_created(path=plan_path) - - saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr] - assert saved["next_number"] == 100 - - def test_returns_early_for_non_plan_file(self, tmp_path: Path) -> None: - """Non-FPLAN filenames cause immediate return.""" - mod = _import_plan_file() - mod._load_registry = MagicMock() - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - non_plan_path = str(tmp_path / "README.md") - mod.handle_plan_file_created(path=non_plan_path) - - mod._load_registry.assert_not_called() # type: ignore[union-attr] - mod._save_registry.assert_not_called() # type: ignore[union-attr] - - def test_logs_operation_on_success(self, tmp_path: Path) -> None: - """Calls json_handler.log_operation after adding new plan.""" - mod = _import_plan_file() - mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1}) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - from aipass.trigger.apps.handlers.json import json_handler - - json_handler.log_operation.reset_mock() # type: ignore[union-attr] - - plan_path = str(tmp_path / "FPLAN-0001.md") - mod.handle_plan_file_created(path=plan_path) - - json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr] - "plan_event", {"success": True} - ) - - -class TestHandlePlanFileDeleted: - """Tests for handle_plan_file_deleted.""" - - def test_open_plan_removed_from_registry(self, tmp_path: Path) -> None: - """Open plan is deleted entirely from the plans dict.""" - mod = _import_plan_file() - mod._load_registry = MagicMock( - return_value={ - "plans": {"0042": {"status": "open", "file_path": "/some/FPLAN-0042.md"}}, - "next_number": 43, - } - ) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - plan_path = str(tmp_path / "FPLAN-0042.md") - mod.handle_plan_file_deleted(path=plan_path) - - mod._save_registry.assert_called_once() # type: ignore[union-attr] - saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr] - assert "0042" not in saved["plans"] - - def test_closed_plan_marked_archived(self, tmp_path: Path) -> None: - """Closed plan gets archived flag instead of being removed.""" - mod = _import_plan_file() - mod._load_registry = MagicMock( - return_value={ - "plans": { - "0042": { - "status": "closed", - "file_path": "/some/FPLAN-0042.md", - } - }, - "next_number": 43, - } - ) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - plan_path = str(tmp_path / "FPLAN-0042.md") - mod.handle_plan_file_deleted(path=plan_path) - - mod._save_registry.assert_called_once() # type: ignore[union-attr] - saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr] - assert saved["plans"]["0042"]["archived"] is True - assert "archived_date" in saved["plans"]["0042"] - - def test_processed_plan_marked_archived(self, tmp_path: Path) -> None: - """Plan with processed=True gets archived flag.""" - mod = _import_plan_file() - mod._load_registry = MagicMock( - return_value={ - "plans": { - "0042": { - "status": "open", - "processed": True, - "file_path": "/some/FPLAN-0042.md", - } - }, - "next_number": 43, - } - ) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - plan_path = str(tmp_path / "FPLAN-0042.md") - mod.handle_plan_file_deleted(path=plan_path) - - mod._save_registry.assert_called_once() # type: ignore[union-attr] - saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr] - assert saved["plans"]["0042"]["archived"] is True - - def test_unknown_plan_no_crash(self, tmp_path: Path) -> None: - """Plan not in registry causes no error.""" - mod = _import_plan_file() - mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1}) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - plan_path = str(tmp_path / "FPLAN-9999.md") - mod.handle_plan_file_deleted(path=plan_path) - - mod._save_registry.assert_not_called() # type: ignore[union-attr] - - def test_returns_early_for_non_plan_file(self, tmp_path: Path) -> None: - """Non-FPLAN filenames cause immediate return.""" - mod = _import_plan_file() - mod._load_registry = MagicMock() - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - mod.handle_plan_file_deleted(path=str(tmp_path / "random-notes.txt")) - - mod._load_registry.assert_not_called() # type: ignore[union-attr] - - -class TestHandlePlanFileMoved: - """Tests for handle_plan_file_moved.""" - - def test_updates_location_fields(self, tmp_path: Path) -> None: - """Move updates location, relative_path, and file_path.""" - mod = _import_plan_file() - mod._load_registry = MagicMock( - return_value={ - "plans": { - "0042": { - "status": "open", - "location": "/old/dir", - "relative_path": "old/dir", - "file_path": "/old/dir/FPLAN-0042.md", - } - }, - "next_number": 43, - } - ) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - new_dir = tmp_path / "new" / "location" - new_dir.mkdir(parents=True, exist_ok=True) - dest_path = str(new_dir / "FPLAN-0042.md") - - mod.handle_plan_file_moved( - src_path=str(tmp_path / "old" / "FPLAN-0042.md"), - dest_path=dest_path, - ) - - mod._save_registry.assert_called_once() # type: ignore[union-attr] - saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr] - plan = saved["plans"]["0042"] - assert plan["file_path"] == dest_path - assert plan["location"] == str(new_dir) - assert "last_updated" in plan - - def test_preserves_existing_metadata(self, tmp_path: Path) -> None: - """Move preserves status, closed_reason, memory_created, etc.""" - mod = _import_plan_file() - mod._load_registry = MagicMock( - return_value={ - "plans": { - "0042": { - "status": "closed", - "closed_reason": "completed", - "memory_created": True, - "memory_created_date": "2026-01-15", - "location": "/old", - "relative_path": "old", - "file_path": "/old/FPLAN-0042.md", - } - }, - "next_number": 43, - } - ) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - dest_path = str(tmp_path / "FPLAN-0042.md") - mod.handle_plan_file_moved( - src_path="/old/FPLAN-0042.md", - dest_path=dest_path, - ) - - saved = mod._save_registry.call_args[0][0] # type: ignore[union-attr] - plan = saved["plans"]["0042"] - assert plan["status"] == "closed" - assert plan["closed_reason"] == "completed" - assert plan["memory_created"] is True - assert plan["memory_created_date"] == "2026-01-15" - - def test_unknown_plan_no_crash(self, tmp_path: Path) -> None: - """Plan not in registry causes no error on move.""" - mod = _import_plan_file() - mod._load_registry = MagicMock(return_value={"plans": {}, "next_number": 1}) - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - mod.handle_plan_file_moved( - src_path=str(tmp_path / "FPLAN-9999.md"), - dest_path=str(tmp_path / "new" / "FPLAN-9999.md"), - ) - - mod._save_registry.assert_not_called() # type: ignore[union-attr] - - def test_returns_early_for_non_plan_dest(self, tmp_path: Path) -> None: - """Non-FPLAN destination filename causes immediate return.""" - mod = _import_plan_file() - mod._load_registry = MagicMock() - mod._save_registry = MagicMock() - mod.REPO_ROOT = tmp_path - - mod.handle_plan_file_moved( - src_path=str(tmp_path / "FPLAN-0001.md"), - dest_path=str(tmp_path / "renamed-notes.txt"), - ) - - mod._load_registry.assert_not_called() # type: ignore[union-attr] diff --git a/src/aipass/trigger/tests/test_service_control.py b/src/aipass/trigger/tests/test_service_control.py new file mode 100644 index 000000000..3027fcf10 --- /dev/null +++ b/src/aipass/trigger/tests/test_service_control.py @@ -0,0 +1,140 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_service_control.py - systemd unit lifecycle for the log watcher +# Date: 2026-08-31 +# Version: 1.0.0 +# Category: trigger/tests +# ============================================= + +"""Pins for the systemd control surface extracted from medic.py. + +WHY THIS FILE EXISTS. The extraction (2026-08-31, medic.py 599 -> 526 lines) +did not create these functions, it revealed that nothing tested them: all forty +medic tests patch ``_systemctl`` out, so its body — the subprocess call, the +exit-code reading, the failure path — had never been executed by the suite. +Measured, not assumed: mutating ``return result.returncode == 0`` to +``return True`` left all 40 medic tests green. + +Nothing here shells out to the real systemctl. The subprocess boundary is the +thing under test, so it is the thing replaced. +""" + +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from aipass.trigger.apps.handlers import service_control + + +class TestSystemctl: + """_systemctl reports whether the command succeeded, and never raises.""" + + def test_zero_exit_is_success(self, monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return subprocess.CompletedProcess(cmd, 0, "", "") + + monkeypatch.setattr(service_control.subprocess, "run", fake_run) + + assert service_control._systemctl("start") is True + assert calls == [["systemctl", "--user", "start", service_control.SERVICE_NAME]] + + def test_non_zero_exit_is_failure(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The mutant that survived: returning True regardless dies here.""" + monkeypatch.setattr( + service_control.subprocess, + "run", + lambda cmd, **kw: subprocess.CompletedProcess(cmd, 3, "", "unit not found"), + ) + + assert service_control._systemctl("start") is False + + def test_a_raising_subprocess_is_reported_as_failure_not_an_exception( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing or hung systemctl must not take the caller down.""" + + def boom(cmd, **kwargs): + raise subprocess.TimeoutExpired(cmd, 10) + + monkeypatch.setattr(service_control.subprocess, "run", boom) + + assert service_control._systemctl("is-active") is False + + +class TestIsServiceActive: + """_is_service_active asks systemctl exactly one question.""" + + def test_delegates_to_is_active(self, monkeypatch: pytest.MonkeyPatch) -> None: + asked: list[str] = [] + monkeypatch.setattr(service_control, "_systemctl", lambda action: asked.append(action) or True) + + assert service_control._is_service_active() is True + assert asked == ["is-active"] + + +class TestGetAipassHome: + """Env var wins; the walk is the last resort and never reads the cwd.""" + + def test_env_var_takes_precedence_over_everything(self, monkeypatch: pytest.MonkeyPatch) -> None: + home = Path(tempfile.gettempdir()) / "aipass_home_probe" + monkeypatch.setenv("AIPASS_HOME", str(home)) + + def must_not_run(*a, **kw): + raise AssertionError("git was consulted despite AIPASS_HOME being set") + + monkeypatch.setattr(service_control.subprocess, "run", must_not_run) + + assert service_control._get_aipass_home() == home + + def test_git_toplevel_is_used_when_no_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: + toplevel = str(Path(tempfile.gettempdir()) / "repo_from_git") + monkeypatch.delenv("AIPASS_HOME", raising=False) + monkeypatch.setattr( + service_control.subprocess, + "run", + lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0, toplevel + "\n", ""), + ) + + assert service_control._get_aipass_home() == Path(toplevel) + + def test_falls_back_to_the_marker_walk_when_git_fails(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Never Path.cwd(): the round-4 ruling, still holding after the move.""" + monkeypatch.delenv("AIPASS_HOME", raising=False) + monkeypatch.setattr( + service_control.subprocess, + "run", + lambda cmd, **kw: subprocess.CompletedProcess(cmd, 128, "", "not a git repository"), + ) + sentinel = Path(tempfile.gettempdir()) / "walked_root" + monkeypatch.setattr(service_control, "find_repo_root", lambda **kw: sentinel) + + assert service_control._get_aipass_home() == sentinel + + +class TestEnsureServiceInstalled: + """Installation is skipped when the unit is already there.""" + + def test_existing_unit_short_circuits_before_any_write(self, monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + unit = tmp_path / "trigger-log-watcher.service" + unit.write_text("[Unit]\n", encoding="utf-8") + monkeypatch.setattr(service_control, "_SERVICE_UNIT_PATH", unit) + + def must_not_run(*a, **kw): + raise AssertionError("systemctl was called for an already-installed unit") + + monkeypatch.setattr(service_control.subprocess, "run", must_not_run) + + assert service_control._ensure_service_installed() is True + + def test_a_missing_template_refuses_rather_than_writing_a_broken_unit( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.setattr(service_control, "_SERVICE_UNIT_PATH", tmp_path / "absent.service") + monkeypatch.setattr(service_control, "_TEMPLATE_PATH", tmp_path / "absent.template") + + assert service_control._ensure_service_installed() is False diff --git a/tests/e2e/test_wiring.py b/tests/e2e/test_wiring.py index 7a3b47ec9..d053d6a4e 100644 --- a/tests/e2e/test_wiring.py +++ b/tests/e2e/test_wiring.py @@ -23,7 +23,10 @@ from __future__ import annotations +import hashlib import json +import os +import shutil import subprocess import sys import uuid @@ -342,50 +345,90 @@ def test_t2a_rm_gate_allows_echo(clean_venv: CleanVenv, hook_workspace: Path) -> @pytest.fixture(scope="module") -def routing_root(clean_venv: CleanVenv) -> Iterator[Path]: - """Generate a minimal registry at the repo root pointing at real branches. - - The repo's own AIPASS_REGISTRY.json is mode 0600 / host-absolute and won't - relocate (its paths are the developer's home, absent in CI), so we GENERATE - a minimal registry — faithful to what setup.sh produces — pointing at REAL - branch dirs under this checkout. - - drone validates path containment against the registry's PARENT dir, so the - registry must sit at the repo root for the ``src/aipass/*`` branch paths to - validate. We back up any existing registry and restore it on teardown so a - local run never mutates the working tree permanently. - - We target ``ai_mail`` — a real BRANCH (not an in-process drone module) — so - ``drone @ai_mail --help`` exercises the full resolve -> subprocess -> - execute path, the proof we never previously got green. +def routing_root(clean_venv: CleanVenv, tmp_path_factory: pytest.TempPathFactory) -> Path: + """A throwaway project root with a minimal registry. The repo tree is never written. + + drone validates branch-path containment against the registry's PARENT dir + (``registry_handler._validate_branch_path``), and that parent has never had + to be the repo root -- only the dir the branch paths sit under. So the whole + stand-in lives in a tmp dir and the live ``AIPASS_REGISTRY.json`` is not + touched at all. + + The previous version overwrote the repo's own registry for the duration of + this module, holding the only copy of the real one in a Python variable. + Two things were wrong with that, both measured before this rewrite: + + * a killed interpreter (timeout, INTERNALERROR, power loss) left the real + 22-branch fleet existing NOWHERE -- a ``kill -9`` mid-window was + reproduced against a stand-in and left a 3-row file and no backup on + disk. A backup that lives only in the process that can die is not a + backup; + * under ``-n auto --dist loadscope`` (``macos-test.yml``, and + ``windows-test.yml`` -- only ``ci.yml`` passes ``--ignore=tests/e2e``) + the sibling xdist workers read the synthetic registry for the whole + window. Measured: 40 of 60 polled reads by a parallel process saw the + 3-branch file. A teardown that runs in one process is not a rollback + for the other seven. + + A SYMLINKED stand-in cannot work and should not be reattempted: + ``_validate_branch_path`` calls ``.resolve()``, which follows the link back + into the repo, so containment then fails and the branch is silently dropped. + (It would also need Developer Mode or admin on Windows.) + + The branch dir holds exactly one file. ``apps/ai_mail.py`` imports only + ``aipass.*`` (prax, cli) from the INSTALLED wheel, and ``--help`` returns + before module discovery and before the identity fence -- so no ``modules/``, + no handlers, no passport. Verified: that single file answers + ``drone @ai_mail --help`` with full help text and exit 0. + + ``seedgo`` and ``drone`` are registry rows only; their dirs are created + empty because a nonexistent path currently passes containment by accident + (``Path.resolve()`` is non-strict), and this fixture should not silently + depend on that. """ - src = REPO_ROOT / "src" / "aipass" + root = tmp_path_factory.mktemp("routing_root") + src = root / "src" / "aipass" + for name in ("ai_mail", "seedgo", "drone"): + (src / name).mkdir(parents=True) + + entry_dir = src / "ai_mail" / "apps" + entry_dir.mkdir() + shutil.copy2( + REPO_ROOT / "src" / "aipass" / "ai_mail" / "apps" / "ai_mail.py", + entry_dir / "ai_mail.py", + ) + + # Relative paths, resolved by drone against the registry's own dir -- so no + # absolute paths, and therefore no separator or drive-letter question on + # Windows. registry = { "metadata": {"name": "AIPASS", "version": "1.0.0", "total_branches": 3}, "branches": [ - {"name": "ai_mail", "path": str(src / "ai_mail"), "status": "active"}, - {"name": "seedgo", "path": str(src / "seedgo"), "status": "active"}, - {"name": "drone", "path": str(src / "drone"), "status": "active"}, + {"name": "ai_mail", "path": "src/aipass/ai_mail", "status": "active"}, + {"name": "seedgo", "path": "src/aipass/seedgo", "status": "active"}, + {"name": "drone", "path": "src/aipass/drone", "status": "active"}, ], } + (root / "AIPASS_REGISTRY.json").write_text(json.dumps(registry, indent=2), encoding="utf-8") + return root - registry_path = REPO_ROOT / "AIPASS_REGISTRY.json" - backup = registry_path.read_bytes() if registry_path.exists() else None - try: - registry_path.write_text(json.dumps(registry, indent=2), encoding="utf-8") - yield REPO_ROOT - finally: - if backup is not None: - registry_path.write_bytes(backup) - elif registry_path.exists(): - registry_path.unlink() +def _drone_env(root: Path) -> dict: + """Env for drone subprocesses, pinned to the throwaway root. -def _drone_env() -> dict: - """Env for drone subprocesses (inherits PATH etc.).""" - import os + ``AIPASS_REGISTRY`` is priority 2 in ``registry_handler.get_registry_path``, + above the cwd walk, so the registry is NAMED rather than left to be found. - return dict(os.environ) + ``AIPASS_HOME`` is dropped because ``get_all_branches()`` merges the + AIPASS_HOME registry on top of the primary one -- on a developer machine + that is the live 22-branch fleet, leaking into a test whose entire premise + is a known 3-branch registry. The tests pass either way; dropping it is a + hermeticity fix, not a load-bearing requirement. + """ + env = dict(os.environ) + env["AIPASS_REGISTRY"] = str(root / "AIPASS_REGISTRY.json") + env.pop("AIPASS_HOME", None) + return env def test_t3_drone_systems_lists_branch(clean_venv: CleanVenv, routing_root: Path) -> None: @@ -393,13 +436,39 @@ def test_t3_drone_systems_lists_branch(clean_venv: CleanVenv, routing_root: Path proc = _run( [str(clean_venv.drone), "systems"], cwd=str(routing_root), - env=_drone_env(), + env=_drone_env(routing_root), ) assert proc.returncode == 0, f"drone systems failed:\n{proc.stdout}\n{proc.stderr}" out = proc.stdout.lower() assert "seedgo" in out or "ai_mail" in out, f"no known branch listed:\n{proc.stdout}" +def test_t3_routing_root_never_writes_the_live_registry(routing_root: Path) -> None: + """The fixture must leave the repo's own AIPASS_REGISTRY.json byte-identical. + + Red-first history: ``routing_root`` used to OVERWRITE this file for the whole + Tier-3 module, holding the only copy of the real one in a Python variable. + That is the live fleet anchor -- a killed interpreter left the real registry + existing nowhere, and parallel xdist workers read the synthetic one mid-window + (measured 40 of 60 reads). This pin fails the moment anyone points the + fixture back at the repo tree. + """ + live = REPO_ROOT / "AIPASS_REGISTRY.json" + if not live.exists(): + pytest.skip("no repo registry on this machine (CI checkout) -- nothing to protect") + + before = hashlib.sha256(live.read_bytes()).hexdigest() + + # Exercise the fixture's product exactly as the other T3 tests do. + assert (routing_root / "AIPASS_REGISTRY.json").is_file() + assert routing_root != REPO_ROOT + assert not routing_root.is_relative_to(REPO_ROOT), f"stand-in root is inside the repo tree: {routing_root}" + + assert hashlib.sha256(live.read_bytes()).hexdigest() == before, ( + "routing_root modified the live AIPASS_REGISTRY.json" + ) + + def test_t3_drone_routes_to_real_branch(clean_venv: CleanVenv, routing_root: Path) -> None: """``drone @ai_mail --help`` resolves -> subprocesses -> returns help text. @@ -411,7 +480,7 @@ def test_t3_drone_routes_to_real_branch(clean_venv: CleanVenv, routing_root: Pat proc = _run( [str(clean_venv.drone), "@ai_mail", "--help"], cwd=str(routing_root), - env=_drone_env(), + env=_drone_env(routing_root), ) combined = (proc.stdout + proc.stderr).lower() assert proc.returncode == 0, f"drone @ai_mail --help failed:\n{proc.stdout}\n{proc.stderr}"