Browniie
Your code depends on other people's APIs — Stripe, OpenAI, Twilio, two hundred others — and those APIs change under you. Browniie maps exactly where your code touches which provider (file and line), watches those providers' published specs, SDKs and changelogs daily, and tells you when a change actually affects one of your lines — with a computed fix where one can be computed, and honest, sourced guidance where it cannot.
Scanning, watching, analysis, reporting, and every deterministic fix are fully local: no model calls, no telemetry, your code never leaves your machine. One narrow exception exists and it is opt-in per repo: an AI repair fallback that transmits only the affected function, is gated on measured evidence, and can never auto-apply anything.
Choose a tier (one config value)
Create .browniie.yaml at your repo root — or don't; the default is
the safest tier.
| tier | what it does | choose it when |
|---|---|---|
report (default) |
Notifies — a GitHub issue, Slack, or email when a provider change affects your code. Never edits anything. | You want to hear about breaks. Start here. |
suggest |
Additionally opens a draft pull request with the fix proposal. Never merges. | You want fixes proposed, humans deciding. |
auto |
Applies deterministic fixes only to a branch after every gate passes, with a stated revert path. | You trust replay-validated mechanical fixes on a branch. |
Two things about auto, because they are the point: it requires a
written acknowledgment sentence in your config (an informed opt-in,
not a flag), and LLM-generated fixes are never eligible for it, at
any setting — that is enforced structurally (there is no key or
code path that permits it), because per-fix generation measured
20–45% green-but-wrong and mechanical fixes measured zero.
Install
Browniie is not on PyPI yet; install from the repository or a built wheel (both verified from clean environments):
pipx install git+ssh://git@github.com/shubangowda/browniie.git
# or, from a checkout: python -m build --wheel && pipx install dist/browniie-*.whl
Python 3.11+. That's it for scanning — no accounts, no tokens, no
config files. Or paste docs/github-action-template.yml into
.github/workflows/ to adopt Browniie without installing anything.
First scan (the whole point, in one command)
browniie scan /path/to/your/repo
At the terminal you get the dependency map itself — one line per provider your code touches, with its confidence tier, call-site count, and the files involved:
Browniie scan — your-repo
============================================================
stripe high confidence, 12 call site(s) — billing.py, hooks.py
twilio high confidence, 3 call site(s) — notify.js
------------------------------------------------------------
214 file(s) analyzed; full detail: browniie scan . -o report.json
Piped or written with -o, the output is the full JSON report
(stable, versioned contract): every provider with its evidence
(manifest pins, imports, call sites, raw HTTP calls, config
references), each row carrying a file and line you can open.
Five languages are analyzed: Python, JavaScript/TypeScript, Ruby, Go,
and Java. .gitignore is respected — vendored and generated code is
skipped, and skipped files are counted rather than hidden.
Read the coverage statement first. Every report ends with
coverage: how many recognized source files were analyzed and — the
half most tools hide — how many were NOT (Vue, Kotlin, PHP, Rust...),
per language, with an unanalyzed_share. If 40% of your repo is in a
language Browniie doesn't analyze, the report says so prominently
instead of letting an empty result read as a clean bill of health.
Absence of findings in unanalyzed code is not evidence of absence.
What the evidence tiers mean. Every claim is tiered by what it
actually proves: a manifest pin proves you declare the dependency;
an import proves you load it; a call site proves code invokes the
SDK; raw_http proves a URL is called (with the wrapper chain in
via when the call goes through your own helpers); config proves
only that a URL is known to your codebase. Things Browniie cannot
resolve become explicit unknowns — never silent, never guessed.
Check your environment
browniie doctor
Prints what will and won't work here: Python version, container runtime (needed only for Mend's verified bumps), config presence, token validity. Most first-run failures are environmental; run this before anything else when something seems off.
Enabling the rest of the pipeline
- Watch (
browniie watch --once): fetches the registry's 227 providers' specs/changelogs into an append-only archive. Works immediately; in production a daily CI job runs it (see.github/workflows/watch.yml). - Analyze (
browniie analyze <scan.json> <events.jsonl>): joins your scan with watch events into an Impact Report — "this breaking change touches yourinvoices.py:9" — with honest confidence tiers. - Report (
browniie report ...): delivers the Impact Report as an idempotent PR comment and/or Slack digest. Needs a config file (browniie.yamlorreport_config.yamlin your working directory) with agithub_token; errors name the missing key, never a stack trace. - Mend (
browniie mend plan|render|open-pr ...): turns an Impact Report into a DRAFT pull request — version bumps only where an exact pin, strong evidence, a confirmed breaking event, and a container-verified test run all line up; review annotations otherwise; nothing at all where attribution isn't airtight. Mend is opt-in per repository and its token needs Contents: read + Pull requests: write on the target repo only.
SDK surface diffing (v13) — line-level impact without a spec
Most providers publish no machine-readable spec — but nearly all publish versioned SDKs, which are public source code in the five languages Browniie already parses. Browniie diffs each SDK's PUBLIC SURFACE between releases (methods removed, parameters now required, types changed) and treats the result as a fourth watched artifact class alongside specs and changelogs.
Three claims, kept strictly separate: a surface change is a FACT read
from source; "therefore the API changed" is an INFERENCE — SDK-derived
findings carry source: sdk as their own tier, never merged with
spec findings, and where the two disagree BOTH are shown with sources
named; "therefore your line 41 breaks" is a legitimate JOIN — you call
the SDK, so the surface change affects your call site regardless of
the wire, matched by call chain with no endpoint mapping involved.
The technique was MEASURED before it was trusted (SDK_ACCURACY.md): for three providers publishing both a spec and an SDK, SDK diffing was compared to spec diffing over real history — zero false signals in every hand-verified sample, and it independently found stripe's basil headline changes plus real product removals our spec watching was structurally blind to. Its own blind spots, stated: wire-level VALUE changes (enum values, status codes) have no SDK surface; an SDK can lag or lead its API; and providers with neither spec nor official SDK remain notice-only. Mend never bumps on an SDK-surface breaking change — the newer SDK changes the very symbol your line calls, so a bump would import the breakage; those rows annotate instead.
Provider coverage after v13: 36 spec-bearing / 115 SDK-watched / the rest changelog-only (of 227). Packages are never executed — archives are extracted with traversal guards and parsed as text; only each provider's OFFICIAL package is watched (community SDKs are excluded by audit).
Notifications — how you actually find out
Four channels, all owning the same silence law: a day with nothing relevant produces no message at all. A tool that pings daily gets muted; the credibility of the messages that do arrive depends on the silence between them.
- GitHub issue (the report-tier workhorse): one issue, updated in place on later findings — never an issue-per-day pile.
- PR comment: for repos with an open PR, idempotently updated.
- Slack: a capped digest via your webhook.
- Email: plain SMTP from config you supply (
email:inreport_config.yaml— host, port, from_addr, to_addrs; STARTTLS by default). Browniie ships no mail relay and mails nobody you didn't name.
What the coverage statement means
Every scan report says what was NOT read: per-language unanalyzed file counts and their share. A monolith in an unsupported language never produces a confident-looking empty report — absence of findings in unanalyzed code is blindness, not cleanliness, and Browniie says so on every report.
The numbers behind the fixes (and their caveats)
Measured on a 320-case benchmark (five languages, five change shapes, 26 live third-party repos at pinned commits — three of them deliberately stale or archived, code written between 2016 and 2019 — every case proven broken-then-fixable in a network-less container, the whole corpus measured by ONE authoritative CI ledger that reconciles exactly with the local one, run 32939615425):
- Wrong fixes shipped at auto tier: 0, across every mechanism, every outcome verified with no sampling.
- Deterministic fixes: 183 of 320 (57.2%) — 71 by shape rules, 42 by admitted recipes, 70 by admitted migration maps (seven of those on LIVE third-party repos, five where the real package declares nothing at all — the pure-move law: a sole target move touches no argument byte, so it cannot lose a value). These are the only fixes tier-3 auto may ever apply.
- Overall: 90.0% of breaks end in a useful diff — a deterministic fix (57.2%), a partial with marked holes (14.1%), a context-derived fix (3.1%), or a clearly-labeled model draft (15.6%); 10.0% end as annotations, each carrying its named reason, and 4.4% of the corpus is the honestly-named irreducible remainder: products retired outright (Twilio Fax, Microvisor, Plaid CRA...), where the only correct output is the clear notice. Under the strictest hand-graded reading (every draft verified, no sampling; one wrong and four misleading partials subtracted) the number is 88.4% — both are published, never blended. Model drafts in the shipping configuration measured 45/50 correct-or-holed (90.0%), and the two historic wrong families now end CORRECT: the second oracle rejects them against the artifact's own declarations and the repair loop fixes them from the rejection text (PRECISION.md, v27).
- THE TWO BARS (v25), explained for a beginner. Auto-apply commits with nobody between the fix and your repository, so its bar is provably correct — deterministic mechanisms only, and a model-drafted edit is refused even if it somehow carries a deterministic label (two independent barriers). Suggest opens a DRAFT pull request that you read before merging, so its bar is different on purpose: is this more useful to you than a note saying the API changed? A 75%-correct diff with its uncertain parts marked beats that note, because you are the verification layer — you can accept, edit, or close it, all cheaper than starting from prose. So at suggest tier the model may attempt any shape the deterministic engine refused — the residue only; a row a mechanism serves never reaches a model — under every structural guard (credential refusal, blast radius, traced locations only, tests/CI/manifests untouchable, detectors, value preservation, the container jail).
- THE SECOND ORACLE (v26), and what it means for trust. A passing test suite is a weak specification — the only wrong drafts ever measured were "green" under suites that specified almost nothing (a load-only suite cannot see a string passed where a date is declared). So every candidate fix is now judged twice: by your repo's own tests in the jail, AND by your repo's own type checker (mypy, tsc, sorbet, go build, javac) when you have one configured — baseline-diffed, so your pre-existing type errors are never blamed on the fix. No checker? The archived artifact's own declarations stand in: a literal passed where the provider declares a different type, or a wrapped response whose body is never read through the wrapper, is rejected before any test runs. Every PR body states WHICH oracles verified the fix — and a repo that adopts a type checker gives every future fix a second, independent verification for free.
- What a model-drafted PR means for you: the body says a model wrote it, names the model, states that a passing suite is NOT proof of correctness, quotes the shape's measured wrong rate when it is off the allowlist, and carries a five-point checklist (the successor, your values, THE READ SIDE, any value not from your code, whether the change is really a human's decision). It can never be auto-applied, at any setting.
- The suggest-lane correctness rate, measured twice and stated
plainly: pooled across both full residual-queue runs, 24 of 27
jail-green drafts hand-verified correct (88.9%), and 34 of 37
drafted outcomes correct-or-appropriately-holed (91.9%) — every
diff read against the provider's own migration document
(PRECISION.md). Every wrong was ONE family, replicated across
runs (a legacy->OpenAPI client migration judged by a load-only
suite: string dates where the artifact declares date objects; an
unread
.dataon a response wrapper) — exactly what the checklist's items 2 and 3 point at. Each full-queue run cost about $2.30, because the deterministic engine had already served everything else for $0 — that is the unit economics: the model is paid only for what nothing cheaper can do. - Migration maps are the newest mechanism, and the growth engine. When a provider replaces a product wholesale (Anthropic's completions -> messages, Stripe's basil invoice preview), every symbol-level rule rightly refuses — but the mapping is published in the provider's own migration guide, which Browniie's archive already holds. A map is ONE authored artifact per provider version migration: ordered entries, each expressed over the same closed edit vocabulary as recipes, and every entry cites the provider document that determines it — one uncited entry makes the whole map inadmissible. What the guide does not determine becomes a loud hole, never an invention. A map is admitted only after replaying over every recorded observation at zero wrong, and one admitted map serves every user of that migration forever. How one gets authored: a model reads the archived guide plus both real surfaces and proposes the map as data; the validator kills structural violations; replay is the judge; a human reads the evidence and admits. Eleven are admitted today, and v25 added three kinds: MINED maps (the mapping is already written down in a provider reference or public catalog — aws-sdk-js-codemod's TRANSFORMATIONS.md is AWS-authored and citable — so it is hand-transcribed and replay-judged; no third-party codemod code is ever copied), CROSS-STATEMENT maps (a REBIND replaces a client's construction with the guide's successor, a FOLD deletes a binding and folds its values into the one call that used it — both under an association law that demands resolution-proof the statements belong together, and an all-or-nothing law so half a migration can never apply), and PURE-MOVE serving of undeclared surfaces (a sole target move touches no argument byte, so it may fire where metaprogramming hides the signature — stripe-python's live Invoice.upcoming).
- Partial fixes: 33 (16.0%), counted separately on purpose —
nearly-complete PRs with a deliberately undefined
__BROWNIIE_HOLE__blank that parses but cannot run, so a half-finished migration can never merge quietly. This now includes the dead-argument form: when a provider removed a parameter and its guide names a successor beside it, the dead argument is KEPT (loud, value preserved) and the successor arrives as a hole. - Context-derived fixes: 10 (4.9%, own category, not auto-eligible) — the documented candidate your own code names exactly once; two matches refuse into a hole, and no ranking exists in any code path.
- The archive census (
python run_census.py) ranks what to make deterministic next; with maps, its findings become migrations to author, and that is the standing growth loop: census -> map -> admitted -> every user of that migration served. - The honest caveat: the deterministic share is the hard number, and it rises only through rules, recipes, maps, oracle coverage and the census — never by loosening a gate. Per-fix AI generation measured 20–45% green-but-wrong at baseline (298 hand-verified fixes), which is exactly why it is fenced the way it is — as the bar for UNATTENDED application, while the suggest lane's own measured rate is reported beside every draft. All 11 admitted recipes and all 11 admitted maps replay at zero wrong.
What Browniie does NOT do
- It never edits code it cannot trace to a Watch event AND a Scan record, never rewrites call sites, and only ever opens DRAFT PRs — no merge or auto-approve capability exists in the codebase at all.
- It cannot tell you a version FIXES a breaking change — it verifies your tests pass with the new version (in a network-less container) and says exactly that.
- Providers with neither a spec nor an official SDK can never produce line-level impact rows — you'll get provider-level notices. After v13 that residue is ~76 of 227 providers (the rest are spec-bearing or SDK-watched).
- Recall is imperfect by design: unregistered providers, unanalyzed languages, and unresolvable dynamic code are SURFACED (unknowns, coverage, aggregated records), not silently skipped — but they are not detected.
Privacy & security (the section to show your security team)
Every claim here is enforced by tests in this repository, not just intended. The most important paragraph in this document is the boundary between what is always local and what transmits code:
- Scan, Watch, Analyze, Report are fully local and deterministic, forever. No LLM, ever. The only outbound traffic is Watch fetching PUBLIC provider specs/SDKs, registry-release-date lookups, and the GitHub/Slack posts you configure. Your source never leaves the machine in any of these.
- Mend v1 (version bumps) is local and deterministic. No LLM, no code transmitted.
- Mend v2 (LLM-generated code fixes) TRANSMITS a minimal slice of
your source to a named model provider — and ONLY when you have
explicitly enabled it with a two-factor opt-in that requires
writing an acknowledgment sentence naming the provider into your
config. It is OFF by default;
browniie doctortells you its exact status for any repo. When on, what leaves the machine is the single affected function/class plus the change facts — never the whole file, never the repo — and the payload is refused outright (not silently redacted) if it contains credential-shaped content. Every request and response is recorded to a gitignored audit trail, and the PR body names the provider and model that wrote the diff — and the exact eligibility rule that permitted generating it. A passing test suite does not prove a generated fix is correct — Mend v2 verifies syntax adapts to the change, not that behavior is preserved. This is now MEASURED at scale, not hypothetical (BENCHMARK.md, v16): a 47-case benchmark across five languages and 13 providers, run factorially across two current-generation models, with every one of 298 suite-passing fixes hand-verified against the provider's real migration documentation. Under v15 conditions 20.0% and 16.7% of suite-passing fixes were semantically wrong. Three interventions — deterministic structural detectors, archived migration material added to the payload, and a prompt that makes declining a valued outcome — brought that to 0.0% and 5.3%, at the cost of roughly half the fix rate. The residue is stubborn: migrations needing information the code does not contain stay wrong even with documentation supplied.
How a fix actually reaches your code (v20)
Browniie tries hardest to never involve a model, and when it must, it treats the model's output as a proposal that has to earn its way in.
Deterministic first, always. If the change shape has a rule, the fix is computed from the archived SDK diff. No model is consulted, no code is transmitted, nothing is billed — there is nothing to ask.
Otherwise: a sandbox, then a gauntlet. Your repository is copied to a throwaway workspace. A model proposes the change there — your actual codebase is read-only for the entire run — and the proposal must clear ten gates before a single byte is written back:
| gate | what it checks |
|---|---|
| 0 | can this be computed deterministically? |
| 1 | is this shape eligible at all (measured evidence)? |
| 2 | credential refusal — never transmit secrets |
| 3 | generation, into the workspace |
| 4 | does the result parse? |
| 5 | blast radius; tests, CI and manifests are untouchable |
| 6 | structural detectors for known failure signatures |
| 7 | semantic preservation — do all your values survive? |
| 8 | your test suite, inside the container jail |
| 9 | promotion, byte-checked |
Gate 7 is the one that saved a real migration. On an actual Stripe
upgrade, a model proposed a fix that passed every other check — and
silently dropped two fields the caller was passing (attributes and
inventory, which Stripe's successor API has no home for). Gate 7
asserts that the set of values reaching the provider never shrinks.
Renames pass it by construction; silent data loss cannot.
Nothing is promoted that a container did not run. If no container runtime is available, Browniie runs every static gate, tells you execution was unavailable, and promotes nothing. It will never substitute a weaker check and call the result verified.
The deterministic fix engine (v17) — fixes that cost nothing
Before any model is considered, Browniie tries to compute the fix. Five small primitives do the work, and a "rule" for a change shape is just a short composition of them:
- rename a keyword argument —
max_tokens=64becomesmax_completion_tokens=64; - add a keyword argument — but only with a value it can DERIVE (the version you were on defaulted it, or the migration names exactly one valid value, or it is a variable already in your code);
- positional arguments to keywords — using the archived signature's own parameter order;
- change a call's target — when the arguments are provably identical;
- change an import's target — checking symbol by symbol that the new module exports everything your file actually uses.
Why compose primitives instead of writing a fixer per migration? Because the next twenty change shapes should cost data, not code — the approach Moderne/OpenRewrite built a business on, and the reason a whole SLF4J→Log4j migration there is a list of primitive invocations rather than a bespoke program.
Why it is safe: a primitive fires only on a call site that provably resolves to the changed symbol through the same evidence behind 239 hand-verified claims (never a name match, never a text match); edits are surgical byte ranges, so your formatting and comments survive untouched; two primitives disagreeing about the same bytes refuses rather than merging; and running a rule twice is a no-op. Same input, byte-identical output, every time — asserted by test.
What it costs: nothing. No API call, no payload, no acknowledgment, because no code leaves your machine. Today 6.4% of the benchmark corpus is fixed this way (it was 0%), and the limit is extractor reach rather than correctness — see BENCHMARK.md.
Adding a rule from a future benchmark finding — the growth
mechanism, as a recipe: (1) find a shape the hand verification shows a
model getting right for mechanical reasons; (2) check the archived diff
actually states the transformation — if it needs information the
evidence lacks, stop, it is not derivable; (3) express it as a
composition of existing primitives in fix_rules.py (if you need a new
primitive, that is a signal to re-read DESIGN_fix_primitives.md, not to
add one casually); (4) replay it against every recorded observation of
that shape — 100% correct-or-refuse or it does not ship; (5) record the
classification and reasoning in BENCHMARK.md.
- Generation is gated by MEASURED eligibility (generation_eligibility.yaml — data, reviewed like the registry). Browniie will only attempt a generated fix for change shapes the benchmark cleared a stated evidence bar (>= 10 hand-verified observations, <= 10% wrong overall, zero wrong under the shipped configuration). Today that is required-parameter additions, parameter renames, and — ONLY when archived migration material is available — parameters becoming required. Conditional entries are enforced, not advisory: a shape allowed only with enrichment is never generated without it, and the PR body states which rule and which condition permitted the fix. Browniie will NOT attempt symbol removals (110 verified observations, 21.8% wrong, unmoved by every intervention) or parameter removals with no successor (28.6% wrong, every one a silent drop of the caller's argument). Shapes with no benchmark evidence are excluded by default: untested is not the same as safe. Everything excluded still gets Mend v1's deterministic annotations.
- Artifacts carry metadata, never code contents: paths, line numbers, call chains, provider names, event ids, version strings. Planted-credential tests assert a fake secret placed beside a matched URL and beside a manifest pin reaches no plan, PR body, title, branch name, or annotation. URLs are query-stripped everywhere (keys travel in query strings). The single exception is the PR's own diff — a diff contains the changed lines, and it lives only in your repository.
- Untrusted code runs only in a container jail: Mend's sandbox executes your test suite with --network=none, non-root, read-only rootfs, CPU/memory/PID limits, no inherited environment, and no host mounts beyond the throwaway repo copy. No container runtime -> no verification, stated — host execution does not exist as a fallback. Honest limit: containers are strong isolation, not a boundary against a determined attacker.
- Token scopes, exactly: Report needs Issues: write (PR comments). Mend's opener needs Contents: read + Pull requests: write, scoped to the target repo. Scan/Watch/Analyze need no token at all.
- What Browniie reads: the repo you point it at (respecting .gitignore), the registry, and its own archive. Sandbox test OUTPUT never enters any artifact (it can quote source).
Everything below is the deep record: architecture, per-organ contracts, the honesty rulebook, and the session-by-session verification history. A first-time user doesn't need it; a reviewer deciding whether to trust the tool does.
Can this tool be trusted? PRECISION.md is the running, auditable answer: 223 hand-verified claims across 18 real pinned codebases, cumulative, with the fabricated-attribution count (zero, ever) stated outright — plus disclosure of the defects our own gates caught. READINESS.md states plainly what Browniie is and is not ready for today.
What v11 completes (SWARM is whole)
- Mend is built — the fifth organ, and the first code Browniie has ever had that modifies a repository. See the Mend section below; the two structural guarantees up front: write capability came last (the whole engine was built and tested against disk-rendered output before a single network write existed), and Mend proposes, humans decide — every output is a draft pull request. No merge, push-to-default, force-push, or auto-approve capability exists anywhere in this codebase — not behind a flag, not commented out. Absent capability cannot be accidentally enabled.
- Go is language #4 (
languages/go_adapter.py): go.mod/go.sum manifests, semantic-import-versioning (stripe-go/v76and/v81match one provider), package-scope-is-the-directory resolution maps, cross-package wrappers through the NEAREST go.mod (multi-module monorepos like GitLab work), net/http + resty + fasthttp + grequests- req with cited URL positions, struct-field base URLs, and honest unknowns for interface-typed clients and httptest URLs. Grafana's 6,238 Go files went from a coverage warning (41% unanalyzed) to analyzed (0.7%).
- Java is language #5 (
languages/java_adapter.py): pom.xml + build.gradle(.kts) manifests ->maven_coordinates, imports + static imports + fully-qualified inline usage, builder-pattern clients, marker-gated HTTP fact sites (OkHttp.url, Retrofit.baseUrl, Spring RestTemplate/WebClient, Apache, JDK HttpClient, HttpURLConnection), a fluent chain counted as ONE call site, and @Autowired/@Inject fields as honest absences. Airbyte's Java connectors now show real call sites — and the coverage statement then surfaced the deeper truth (its CDK moved to Kotlin, 44.8% unanalyzed and stated). - 227 providers (127 -> 227; 37 with machine-readable specs), every URL batch-verified live before recording.
- The privacy property is now TESTED, not intended — see "Privacy" below.
What v10 added (three languages, deeper chains)
- Ruby is the third language (
languages/ruby_adapter.pyon tree-sitter-ruby), at full contract parity — Gemfile/gemspec manifests,require+ constant-resolved SDK calls (Stripe::Charge.create), Net::HTTP/HTTParty/Faraday (incl. connection objects)/RestClient/Excon/Typhoeus,@ivarfields, string interpolation, wrappers + multi-hop. Ruby's dynamic hazards (method_missing,define_method, monkey-patching) produce honest unknowns, never guesses. Adding language #4 is the same recipe (top oflanguages/base.py) — Ruby is its worked example. - Multi-hop wrapper attribution (
DESIGN_multihop.md): wrapper chains resolve up toMAX_HOPS = 3with the full chain recorded invia(get_json -> api_request -> requests.get); a chain one hop past the limit surfaces an honest depth-limit unknown carrying the partial chain; cycles terminate and never attribute; poisoning composes transitively, per-entry. The engine is a language-agnostic string-substitution pass inbase.py— one rulebook for all three languages. - Template-aware config matching:
{{config.domain}}.salesforce.comattributes at config tier by reading the anchored literal suffix that exists;api.{{provider}}.com(no known literal suffix) never matches — an honest unknown. - 127 providers at v10 (up from 62); v11 takes it to 227.
- Memory: the string-template index keeps unbounded-depth resolution tree-free; adapters release trees between languages so per-language tree sets never stack. Every one of the 18 tested repos fits a standard 16 GB CI runner (largest: n8n at 4.65 GB).
What v9 surfaces that used to be silent (explained for a beginner)
Four kinds of API-calling code used to produce NOTHING — the worst kind of miss, because a report with no rows reads as "all clear". Each is now an honest statement, and it matters what each one does NOT claim:
- URL literals in data structures (an endpoint sitting in a dict,
array, constant, or return statement). Surfaced at the CONFIG tier
with
"source": "code-literal". It claims the URL is known to the codebase — not that any call is made with it. - Third-party HTTP wrapper libraries (saleor routes everything
through
requests_hardened.Manager, whosesend_requesttakes the URL SECOND). The registry'shttp_wrapperstable records each library's documented signature — adding one is zero code, and every entry cites where its argument positions were verified, because a wrong position would extract the wrong string as the URL. A method whose position isn't confidently known emits an unknown NAMING the library — never a positional guess. - Framework-mediated HTTP (n8n nodes call
this.helpers.request*; URLs are assembled at runtime). Surfaced as aggregatedframework_httprecords — "API-calling code here, mediated by , not statically resolvable". It never names a provider, and the provider is NEVER inferred from the node's filename or directory (a path is a naming convention, not evidence). Matching is double-keyed: helper chain AND a framework marker import. - Known wrappers passed as arguments
(
rate_limit_builder(...)(rl_requests.get)). An unknown states the handoff and that the one-hop boundary was NOT extended.
Volume law. Every surfaced class aggregates: framework records are one row per (framework, helper) with counts and capped file lists; config hits aggregate per (provider, host); mined-literal harvest rows aggregate per host. A 1,283-site framework or a 75-copy fixture URL is counted honestly without drowning the report.
Language coverage. Every report now carries coverage — analyzed
and UNANALYZED source file counts per language and an
unanalyzed_share. A Ruby monolith states "78% of recognized source
was not analyzed" prominently (the CLI prints a NOTE at ≥15% and a
WARNING at ≥50%) instead of producing a confident-looking empty result.
Absence of findings in unanalyzed source is not evidence of absence.
Browniie watches the external web APIs a codebase depends on (Stripe, Twilio, OpenAI, ...) and will eventually alert — and help fix — when those APIs ship breaking changes. The pipeline is called SWARM: Scan, Watch, Analyze, Report, Mend. This repo contains all five layers:
- Scan reads a codebase and produces a JSON "API dependency manifest": which providers the code talks to, and exactly where (file + line).
- Watch monitors those providers from the other side: it fetches their machine-readable specs and changelogs on a schedule, archives every version forever, diffs consecutive versions, and emits a stream of classified change events (breaking / safe / info).
- Analyze joins the two and produces the Impact Report: "Stripe removed
GET /v1/invoices/upcoming(Watch event) and yourinvoices.py:13calls it (Scan evidence)" — with an honest confidence tier on every claim. - Report delivers that Impact Report where a human will actually see it: an idempotent PR comment with the file/line detail, and a short Slack digest that links to it.
Nothing in any layer uses an LLM. All four are deterministic: same inputs → same outputs, forever. Scan never executes the code it reads; Watch and Analyze classify by rules, not judgment; Report's only prose is static template wording written in report.py.
Quick start
Setup (once): Python 3.11+, then:
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
Scan any repo — Python, JS/TS, or mixed; add -o reports/name.json to write
a file instead of printing:
.venv/bin/python run_scan.py /path/to/some/repo
Run one watch cycle over every registry provider (in production the GitHub Actions workflow runs this daily — the process is one-shot by design; there is no daemon):
.venv/bin/python run_watch.py # or --provider stripe
Join a scan report with watch events into an Impact Report:
.venv/bin/python run_analyze.py reports/myrepo.json reports/watch/<run>.events.jsonl
Deliver it (after the one-time "Report setup" below):
.venv/bin/python run_report.py reports/analyze/myrepo.impact.json --pr owner/repo#123
Run the whole test suite (fixtures, unit tests, real-world validation for all layers):
.venv/bin/python -m unittest discover -s . -p "test_*.py"
Architecture: one orchestrator, one adapter per language
run_scan.py thin CLI
scanner.py orchestrator — file discovery, adapter routing,
config sniffing, confidence, report assembly
languages/
base.py the LanguageAdapter contract + ALL shared
matching logic (domains, URL classification,
the multi-hop string engine)
python_adapter.py Python, on the stdlib `ast` module
js_adapter.py JavaScript/TypeScript, on tree-sitter
ruby_adapter.py Ruby, on tree-sitter (language #3; the
worked example of the adapter recipe)
go_adapter.py Go, on tree-sitter (language #4)
java_adapter.py Java, on tree-sitter (language #5)
mend.py Mend (SWARM step 5): tier gate, plan,
disk render, sandbox verify — the draft-PR
engine (see the Mend section)
registry.yaml the known-API registry — data, never code
fixtures/ fake repos with expected.json ground truth
realworld/manifest.yaml pinned public repos + hand-verified expectations
The idea, for a beginner: the orchestrator knows nothing about any
programming language. It finds files, hands .py files to the Python
adapter and .js/.ts/... files to the JS adapter, and each adapter answers
the same four questions in its own language's way (what's declared? what's
imported? what's called? what's fetched over HTTP?). Everything that must
behave identically across languages — "is this URL a Stripe URL?" — lives in
ONE shared place (languages/base.py), so no language can drift.
Python parses with the standard library's ast; JS/TS parses with
tree-sitter (the industry-standard parsing library with grammars for ~40
languages — the road every future language walks). The adapter interface is
what makes that mixed-parser reality invisible.
Adding a language never touches the orchestrator. languages/base.py
contains the complete recipe (a future session could add Ruby from its
comments alone): write one adapter class, add per-language fields to
registry.yaml, register one line in ADAPTERS, add fixtures.
The five layers
Each layer answers a different version of "does this code use provider X?", in every language scanned:
1. Manifest. What's declared: requirements.txt / pyproject.toml
(Python), package.json dependencies + devDependencies (JS). Cheapest
signal — the developer wrote it down on purpose — but says nothing about
where, and can be stale.
2. Imports. What's wired in: import stripe as payments,
import Stripe from 'stripe', const twilio = require('twilio'). This
layer also builds the per-file alias map (local name → real package)
that lets the next two layers see through renames instead of being fooled.
3. Call sites. The strongest evidence: the exact lines that invoke the
SDK — payments.Charge.create(...), new Stripe(key),
client.chat.completions.create(...). Both languages track the one-level
client pattern (client = OpenAI() … client.x.y(...)) — these file+line
records are what Watch/Mend will point at.
4. Raw HTTP. Code that skips SDKs: requests.post("https://api.stripe.com/..."),
fetch(...), axios instances with a baseURL, HTTP client objects
(requests.Session(), aiohttp.ClientSession()). URLs are resolved as far
as literals allow — through f-strings/template literals, + concatenation,
module-level and single-assignment local constants, parameter defaults,
same-file single-return helpers, and single-hop wrapper calls attributed
to the caller's line (the bounded v2.3 powers — see the contract
changelog below; cross-file wrappers and runtime config stay honest
unknowns).
Every resolved URL is then classified by one shared rulebook:
provider evidence, or unrecognized_urls (public host, not in the registry
— the signal for growing it), or dropped (internal hosts). Anything
unresolvable is recorded in unknowns — an honest "there's an HTTP call
here I can't attribute" is a feature, never a silent miss.
5. Config sniffing. Provider domains sitting in .env, *.yaml,
*.json, *.toml, *.ini, *.cfg — read as plain text, matched with the
same dot-anchored rule as Layer 4 (lookalike domains don't pass). Reports
carry only the trimmed URL token, never whole config lines, which can hold
secrets next to URLs.
Confidence levels
| Level | Meaning |
|---|---|
high |
At least one call site or raw HTTP hit — the code demonstrably talks to the API. |
medium |
Imported or declared in a manifest, but never seen used. |
low |
Only a domain in a config file — a clue, not proof. |
Only providers with at least one piece of evidence appear in a report.
Output schema (contract) — v2.6
This structure is a frozen contract. Every future SWARM layer (Watch, Analyze, Report, Mend) consumes exactly this shape.
v2.4 → v2.6 changelog (v2.5 was behavioral only; v2.6 is additive):
- NEW top-level
coverage—analyzed(mirrorslanguages),unanalyzed(per-language file counts for recognized-but-unsupported source),unanalyzed_share. - NEW top-level
framework_http— aggregated framework-mediated HTTP records:{framework, detail (helper), sites, file_count, files (capped 20), reason}. Never carries a provider. - Config records may carry provenance/aggregation fields —
source: "code-literal"(mined from source data structures vs. sniffed from a config file),sites(occurrence count),files(capped list). Config-file hits aggregate per (provider, host). url_lineon raw_http/unknown records — the line that SUPPLIED the URL when a carrier on a different line provided it (evidence stays anchored to the transport-call line; both halves of the historical near-miss ambiguity are now on the record).siteson mined-literalunrecognized_urlsrows (per-host aggregation for the registry-growth harvest).- Registry gains three data-only sections consumed by Scan:
http_wrappers,http_frameworks, and per-providerconfig_domains(narrower domains for the config tier only). - Report content object:
footer.marker(additive) — the idempotency marker now hashes the FULL scanned-subject path (opaque), so monorepo subdirectory reports keep separate PR comments.
v2.4 → v2.5 (behavioral only, like v2.3 — no new fields): the
precision-hardening session added OBJECT-METHOD WRAPPER attribution.
A module-level exposure of a repo-local class whose methods make direct
HTTP/SDK calls (session = RateLimitedSession() … session.get(url),
or onyx's real shape — a class ALIAS whose attributes are verified
verbatim-forwarding re-bindings of requests.get) now attributes at the
caller's line, via: "ClassName.method". The rules mirror Python's own
attribute binding: def methods resolve only through INSTANCES, plain-
function attributes only through ALIASES (each other quadrant would put
the URL in an argument slot Python never delivers it to — refused).
Boundary, unchanged in spirit: module-level exposures only; an instance
constructed inside a function or passed as a parameter NEVER resolves.
Methods are also excluded from bare-name wrapper lookup in both
languages (a method is not callable by bare name — a small pre-existing
misbinding vector, closed).
v2.2 → v2.4 changelog (additive — the wrapper-attribution session):
- Wrapper attribution. Any
call_sites/raw_http/unknownsrecord MAY carryvia: the repo-local wrapper hop it was attributed through (e.g."rl_requests.get"). One hop, same repo only: a caller of a wrapper resolves, a caller of a caller does not. The hop is recorded so the attribution is auditable — a reader can open the wrapper and check it. Symmetric across Python and JS/TS. - Class/instance field base URLs.
self.base_url(Python) /this.baseUrl(JS) resolve when the field is a single string literal set at class level or in__init__/constructor. Reassigned-to-a- different-literal or runtime-sourced fields are poisoned (never guessed); subclass inheritance is out of scope for v1. - NEW top-level
proxies. A proxy library (litellm, langchain'sinit_chat_model) routes to a runtime-chosen provider. Each routing call emits a proxy record —{proxy, file, line, detail, resolvable_provider (ALWAYS null), reason}— naming the PROXY, never a provider. Kept strictly out of theprovidersmap: it is a different KIND of fact (see "The proxy honesty design" below). - NEW top-level
model_literals. A string LITERAL in source matching a registrymodel_prefixesentry ("claude-sonnet-4-…") yields a{provider, file, line, detail}record — a clearly-labeled lower tier (intent, not a proven call path). A model value from a variable, call, config, or${hole}template produces NOTHING, ever.
v2.1 → v2.2 changelog (additive):
- Discovery respects
.gitignoreby default — nested files, negation (!pattern), directory-only patterns (trailing/), anchored vs floating patterns, character classes, and**globs. The hardcoded skip floor (.git/, node_modules/, venvs, pycache) still applies even without any .gitignore. Opt out with--no-gitignore. Deliberately unsupported (documented, not pretended): backslash escapes, core.excludesFile / .git/info/exclude, and re-including files under an excluded parent directory (git's own documented behavior). - NEW top-level
files_skipped: gitignore-excluded file count — the exclusion is visible, never silent. (Why this exists: the first dogfood self-scan drew 99% of its evidence from gitignored data directories.)
v2.2 → v2.3 changelog (behavioral, no schema change): bounded
cross-function URL resolution — module-level constants resolve inside
function bodies, per-scope single-assignment locals resolve at their use
sites (any unresolvable or repeated assignment POISONS the name — the
scanner refuses rather than guesses), parameter defaults resolve, calls
to same-file single-return-literal helpers resolve, and a same-file
wrapper function whose HTTP call depends on its parameters attributes
the request to the CALLER's line when the caller's arguments supply a
real URL (post_json(API_BASE + "/v1/refunds", ...) is evidence at that
line). Symmetric in Python and JS/TS. Still honest unknowns: cross-FILE
wrappers, class fields, runtime config, and multi-hop chains.
v2 → v2.1 changelog (additive):
- Every
call_sitesrecord gains"kind":constructor|method|other(definitions inlanguages/base.py). A heuristic priority hint —methodapproximates "may hit the network" — never proof. - Manifest discovery is repo-wide:
requirements.txt/pyproject.toml/package.jsonare found at any depth (same skip rules as source scanning), and each manifest record'sfileis its repo-relative path.
v1 → v2 changelog (deliberate, one-time, additive):
- NEW top-level
languages: which languages were scanned, with file counts. - NEW top-level
unrecognized_urls: fully-resolved external URLs that match no registry provider (public hosts only). v1 dropped these; v2 surfaces them as the discovery engine for growing the registry. - Ordering is now contractual: every list is sorted by (file, line). v1 left ordering to AST-walk luck.
- Everything else is unchanged — v1 consumers keep working.
{
"repo": "path/that/was/scanned",
"scanned_at": "2026-08-07T12:00:00+00:00",
"files_scanned": 6,
"languages": [ {"language": "javascript", "files": 2}, {"language": "python", "files": 3} ],
"providers": {
"stripe": {
"display_name": "Stripe",
"confidence": "high",
"evidence": {
"manifest": [ {"file": "package.json", "detail": "stripe@^14.5.0 (dependencies)"} ],
"imports": [ {"file": "billing.py", "line": 2, "detail": "import stripe"} ],
"call_sites": [ {"file": "billing.py", "line": 8, "detail": "stripe.Charge.create"} ],
"raw_http": [ {"file": "sync.js", "line": 12, "detail": "https://api.stripe.com/v1/charges"} ],
"config": [ {"file": ".env", "line": 2, "detail": "https://api.stripe.com"} ]
}
}
},
"unknowns": [ {"file": "sync.py", "line": 8, "library": "requests", "reason": "URL built from variable"} ],
"unrecognized_urls": [ {"file": "push.js", "line": 4, "url": "https://api.segment.io/v1/track", "domain": "api.segment.io"} ],
"errors": [ {"file": "legacy.py", "reason": "SyntaxError: ..."} ]
}
Field by field:
repo— the path that was scanned, exactly as given on the command line.scanned_at— UTC timestamp of the scan, ISO 8601.files_scanned— count of distinct files the scan opened (sources + config + manifests).languages— one entry per language that had at least one source file:language(adapter name;javascriptcovers TS too) andfiles(source files opened, broken ones included).providers— one entry per detected provider, keyed by registry key; providers with zero evidence are omitted entirely.display_name— human-readable provider name, from the registry.confidence—high/medium/low, per the table above.evidence.manifest— declared-dependency hits:file+detail(the requirement line, orpackage@version (section)for npm).evidence.imports— import/require statements:file,line,detail(the statement as written).evidence.call_sites— SDK invocations:file,line,detail(the dotted chain as written; JS constructors readnew Stripe),kind(constructor/method/other— see base.py; heuristic, not proof of network traffic).evidence.raw_http— direct HTTP calls to provider domains:file,line,detail(the URL; unresolved holes shown as{}).evidence.config— provider domains in config files:file,line,detail(trimmed URL token only — never the whole line).
unknowns— HTTP calls whose URL could not be resolved statically:file,line,library,reason. Honest blind spots, never dropped.unrecognized_urls— resolved URLs on public hosts that match no registry provider:file,line,url,domain. Internal hosts (localhost, IP literals, hosts without a public TLD) are excluded.errors— files the scan had to skip:file,reason. A broken file never kills a scan.- Every evidence/unknowns/unrecognized/errors list above is sorted by
(file, line) — contractual since v2. (
languagessorts by name; it has no file/line.)
Real-world validation
Fixtures are exams the scanner wrote for itself; realworld/manifest.yaml
holds exams it didn't write: five public repos (Stripe, OpenAI ×2, Plaid,
Slack official samples) pinned to exact commits, with expectations
established by reading the code by hand — every bound in the manifest cites
file/line proof. test_scan_realworld.py clones each pin shallowly into the
gitignored realworld/clones/ and enforces the bounds; it skips cleanly
when offline.
Beyond the manifest, six large/diverse real codebases have been scanned
and HAND-verified at pinned commits (onyx, cal.com, airbyte, n8n,
saleor, stripe-payments-demo — per-repo detail in
realworld/oss_findings.md). The cumulative scoreboard is
PRECISION.md: 83 verified claims, zero fabrications
ever. The registry stands at 227 providers across every major category — the backlog entries demanded by real
usage found in those scans — never guessed.
Known limitations (v9 — post-surfacing taxonomy)
The complete taxonomy — 14 named classes with frequencies and a
SILENT-vs-surfaced verdict on each — lives in
realworld/oss_findings.md. The v8 silent quartet (framework-mediated
HTTP, URL-literals-in-data-structures, third-party wrapper libraries,
callables-passed-as-arguments) is now SURFACED and verified on the
repos that measured it. What remains:
- Still silent, named (2): callers beyond the one-hop wrapper
boundary (by documented design — the wrapper's own line surfaces;
multi-hop needs its own design session), and templated tenant hosts
in config (
{{config.domain}}.salesforce.com— one measured site; needs template-aware config matching). - Registry-data frontiers (not structural silences): an UNREGISTERED wrapper library, framework, proxy, or provider is invisible until its data entry exists — extending any of them is zero code.
- Perennials: dynamic imports are invisible to static analysis;
minified bundles excluded by design; private-host filtering is a
simple heuristic, not the public-suffix list; model-literal and
config line-grep matching have documented lookalike noise (a
gemini--prefixed non-model string, a commented-out example URL) — clearly labeled at their lower tiers, never a provider attribution above them; unsupported-language source is now REPORTED per repo incoveragerather than silently omitted (Ruby leads the measured queue at 11,214 Discourse files, Go second at 6,238 Grafana files).
Adding a provider (zero code)
Open registry.yaml, add an entry, done — every layer in every language
keys off the registry:
mailgun:
display_name: "Mailgun"
pip_packages: ["mailgun"] # as spelled in requirements.txt
import_names: ["mailgun"] # as spelled in `import X`
npm_packages: ["mailgun.js"] # as spelled in package.json
js_import_names: ["mailgun.js"] # as imported/required in JS
api_domains: ["api.mailgun.net"] # dot-anchored, subdomains included
docs_url: "https://documentation.mailgun.com/"
Adding a language (zero orchestrator changes)
The complete recipe lives at the top of languages/base.py — one adapter
class, new per-language registry fields, one line in ADAPTERS, fixtures.
The contract (record shapes, the shared URL classifier, what each method
returns) is documented there field by field.
Watch (SWARM step 2)
What a watch cycle does
run_watch.py runs one cycle: for every provider in the registry with a
watch block, it fetches each recorded source (politely: conditional
requests, honest User-Agent, per-fetch timeout), archives the result, and —
when a source's content actually changed — diffs the new version against
the previous one and emits classified change events. One provider's outage
never aborts the cycle; failures land in the run summary's errors.
The archive — and why it's built the way it is
The archive is the moat. Every snapshot stored today is historical data a competitor starting later can never recreate. That dictates the design:
archive/is append-only, forever. No code path deletes or rewrites anything in it; there is deliberately no cleanup function and no retention policy. Corruption is handled by failing loudly — never by overwriting history. Losing archive data is the worst possible bug in this codebase, worse than a crash.archive/is its own git repository, separate from the code repo (which gitignores it). Watch initializes it on first run. Data and code have different lifecycles: the archive repo's history IS the historical record —git loginside it is the timeline of an API ecosystem — and git provides dedupe, integrity hashes, and time travel for free.- Layout per source:
archive/<provider>/<source-slug>/current.<ext>(the latest version) plusmeta.jsonl— an append-only log with one JSON line per fetch, forever:{fetched_at, url, content_sha256, http_status, etag, last_modified, changed, archive_commit}; failure lines carry an additionalerrorstring, and html lines that fetched a body carrytext_sha256(see the v1.1 changelog). Content changes are committed to the archive repo; unchanged polls cost one meta line, no commit. - Replication (v1.1, merge-aware since v1.3): when
watch_config.yamlenablesarchive_replication, every cycle ends by reconciling with the remote and pushing. If CI and a local cycle diverged, the cycle MERGES (never rebases — meta lines cite local commit hashes): fetch logs are unioned with the append-only prefix property verified per file (a violation aborts loudly — rewritten history is never auto-resolved), conflicting snapshots keep the newest fetch with the other side preserved in the merge's history, and anything else conflicting refuses with instructions. Only the remote name lives in config — the remote's URL is per-machine state (in CI it embeds an access token) and is configured once per archive clone withgit -C archive remote add origin <url>. A failed push is a loud error in the run summary (archive_pushfield) and a nonzero exit fromrun_watch.py, but it never blocks or rolls back local archiving: local truth first, replication second. Once configured, replication is part of the archive law — never disable it to silence a failure.
Where sources come from: the registry watch block
Each provider entry in registry.yaml carries a watch block:
spec_sources (machine-readable API specs — every URL verified live when
recorded) and changelog_sources (public changelog pages; html,
markdown, or rss). Providers with no public spec are recorded honestly
with spec_sources: [] — never faked. Adding or removing a source is a
data change; no code changes, ever.
The differ
For OpenAPI sources, spec_diff.py compares consecutive versions:
- Local
$refs are resolved on demand with cycle protection; external refs surface asunresolved_refinfo events — never silently dropped. - Every change is classified breaking (removals, new requirements,
type changes, enum removals, auth changes), safe (additions, relaxed
requirements, docs-only churn — coalesced to one event per operation),
or info (anything the rules can't confidently classify — honest
unknowns, never guesses). Three stream-level categories are emitted by
Watch itself, outside the differ:
changelog_updated(info),events_suppressed(info), andcomponent_changed(rollups). The differ's full category list and boundary decisions are documented inspec_diff.py's docstring. - Changelog pages are text-level only in v1: change is detected by
hash, every version is archived, and one
changelog_updatedinfo event carries the added-text excerpt (bounded). No semantic parsing of prose — that is future-layer work.
Validated against reality (realworld/watch_manifest.yaml): pinned
historical Stripe and Twilio spec versions months apart, with expected
changes hand-verified from the providers' own release notes before the
differ ever ran; a multi-megabyte Stripe diff completes in well under a
second.
Watch contract v1 (frozen)
Each run writes reports/watch/<run_id>.events.jsonl (one event per line)
and <run_id>.summary.json. This is the second frozen contract — Analyze
consumes it.
Event fields:
event_id— deterministic 16-hex id: sha256 overprovider|source_url|path|category|detail. Same change re-derived from the same data → same id; replays dedupe for free. No random UUIDs.provider— registry key (stripe).source_url— the watched document the change came from.detected_at— ISO-8601 UTC timestamp of the run that saw it.category— taxonomy label (endpoint_removed,enum_values_added,changelog_updated, ...).severity—breaking|safe|info.path— JSON-pointer-style location inside the spec (RFC 6901 escaping:/paths/~1v1~1charges/post/...);/for whole-document events.detail— human-readable one-liner.before/after— OPTIONAL small values where sensible (e.g. old/new type). Absent otherwise.spec_version_before/spec_version_after—{sha256, archive_commit}of the two versions diffed, so the exact bytes behind any event are forever retrievable from the archive repo.
Run summary fields: run_id, started_at, providers_checked,
sources_fetched, sources_changed, events_total,
events_by_severity ({breaking, safe, info}), errors
([{provider, url, error}]).
Contract changelog — v1 → v1.1 (all additive)
Event stream:
component— OPTIONAL on per-site events: JSON pointer of the shared definition (/components/schemas/Charge) the change originates in, when the differ descended through a$refto find it. Absent when the change is inline.component_changedrollup events — one per component that originated changes in a diff:path= the component pointer,severity= max of the children's,children= sorted list of the per-site events'event_ids,operations= sorted operation pointers touched. Per-site events remain the canonical record; rollups are the aggregated view ("Charge changed, touching 50 operations") and are counted separately in the summary, never inevents_by_severity.events_suppressedevents — whenwatch_config.yamllists suppression patterns, matching info events (and only info — the code refuses to collapse breaking/safe) are collapsed into one counted summary event per affected source. Nothing is silent: the count is in the detail, and every collapsed change is re-derivable from the archive viaspec_version_before/after.- html changelog sources: change detection and excerpts now use
extracted text (stdlib html.parser; script/style/noscript stripped,
whitespace collapsed), killing SPA build-id/nonce false positives. The
RAW html is still what gets archived. meta.jsonl lines for html sources
gain
text_sha256— on lines that fetched a body (304 and error lines have no body to hash and omit it).
Run summary: gains archive_push (null when replication is not
configured, else {remote, ok, error}), suppressed_events,
rollup_events.
Contract changelog — v1.1 → v1.2 (all additive; the CI data-loss fix)
Found in production: CI runs left snapshots but ZERO meta.jsonl fetch logs on the remote (the runner is destroyed each run, and meta lines relied on riding into the next content commit that never came). v1.2:
- Every cycle ends with one fetch-log sweep commit staging all
meta.jsonl files; the run summary gains
fetch_log_commit(null when the cycle recorded nothing new). - Change detection derives the previous version's hash from the archived bytes, never from the meta log — an absent or lagging log can no longer fake a change (or hide provenance). The log records fetches; the file is the truth.
- Event provenance survives a log-less clone: when no meta line records
the previous version's commit, it is recovered from
git logon the source's path. archive_commitin meta lines is only trusted when the commit actually succeeded (no more stamping unrelated hashes on no-op commits).
Known limitations (Watch v1.1)
- Changelog prose is not semantically parsed — hash-detect (on extracted text for html), archive, excerpt. Meaning extraction is future-layer work.
- External
$refs are not resolved (surfaced asunresolved_ref). - Polling, not push — changes are seen at the next cycle, not the moment they ship.
- Spec coverage is uneven across providers — 37 of 227 have machine-readable specs today (8 seeded + harvest additions incl. dub, atlassian, paypal, asana, adyen, cloudflare, digitalocean, vercel, datadog); the rest are changelog-only or scan-only, each with its reason recorded honestly in the registry.
- Provider release notes and spec files can disagree — the Twilio and plaid validations both found documented changes that never landed in the pinned spec file. Watch reports what the watched document actually says, nothing more.
Analyze (SWARM step 3)
Analyze answers the question the first two layers exist for: does this upstream change affect THIS codebase, and where? It joins a Watch event stream against a Scan report and writes the Impact Report. No LLM, no guessing — the join is deterministic, and every claim carries the tier of evidence behind it.
The join, explained for a beginner
A Watch event says where in the spec something changed, as a JSON
pointer: /paths/~1v1~1charges/post/parameters/currency. RFC 6901
unescaping (~1→/, ~0→~) recovers the endpoint (/v1/charges) and
the HTTP method (post). A Scan report says where in the code a
provider is used, as two kinds of evidence:
- Raw URLs (
raw_httpevidence): the code literally containshttps://api.stripe.com/v1/invoices/upcoming. Matching is segment-wise and template-aware ({charge}matchesch_123; a code f-string{}matches a template), with two overclaim brakes: segment counts must be EQUAL (no prefix conflation), and a literal endpoint shadows a template match —/v1/invoices/upcomingin code never satisfies events on/v1/invoices/{invoice}, because the table knowsupcomingis its own endpoint (router semantics; found by the real-world exam). - SDK call sites (
call_sitesevidence,kind == "method"only):stripe.checkout.Session.createnames no URL, so Analyze builds a method↔endpoint table from the provider's ARCHIVED spec and applies per-language naming-convention rules to propose candidate operations. Only candidates that EXIST in the table are ever claimed — a convention rule can propose nonsense, but nonsense isn't in the spec. Because removed endpoints exist only in PRE-change specs, the table is the union of the archive's current version and everyspec_version_beforethe events cite (retrieved withgit showfrom the archive repo — the archive is a time machine).
Honest confidence tiers
| Tier | Evidence | Claim strength |
|---|---|---|
direct |
changed endpoint matches a literal URL in the code | this exact line calls the exact changed endpoint |
endpoint |
SDK call site maps to the endpoint via the table | strong, one deterministic inference deep |
provider |
high-confidence provider usage, no endpoint link | "you use Stripe; Stripe shipped a breaking change; we could not narrow it" — surfaced as a notice, NEVER attributed to a file/line |
Never emitted: guesses. Events for providers the codebase doesn't use are
counted (unmatched_events), not itemized. Info events (changelogs,
suppressed-churn summaries, ref problems) land in not_assessable,
honestly labeled. Safe changes with no endpoint link are counted
(safe_unlinked), not itemized — hundreds of harmless additions per
release window must not bury the breaking signal.
Method↔endpoint mapping coverage (roadmap, not shame)
| Provider | Status |
|---|---|
| stripe | First-class, validated: python classic (stripe.checkout.Session.create) + node resource-walk (stripe.paymentIntents.confirm); 22 chains hand-verified against docs.stripe.com/api AND re-validated against the real archived spec |
| openai, twilio, github, sendgrid, plaid, discord | Generic resource-walk rule over their archived spec (existence-guarded); unvalidated — treat endpoint-tier hits as strong hints until a hand-verified chain set like Stripe's exists |
| slack | Falls to provider tier by construction: its URL style (/chat.postMessage) defeats the resource walk — honest empty mapping |
| anthropic, google-maps, shopify, hubspot, notion, aws | No archived machine-readable spec → no table → provider tier |
To add first-class mapping for a provider: read its SDK docs, hand-verify
15+ real chains against its API reference (the Stripe methodology in
test_analyze.py), add convention rules to chain_candidates() ONLY if the
generic walk fails, and encode the chains as test expectations. The table
itself never needs authoring — it derives from the archive.
Impact Report — Analyze contract v1 (frozen)
run_analyze.py writes reports/analyze/<repo>.impact.json:
analyzed_at— ISO-8601 UTC of the analysis run.scan_report—{repo, scanned_at}of the Scan input.events—{refs, total}: run ids / file names and event count.impacts— sorted by (provider, event_id), each:event_id,provider,severity,category,path,detail— copied from the event;endpoint— the recovered endpoint (null when the event isn't under/paths/).affected— sorted by (file, line):{file, line, evidence_kind (raw_http|call_site), tier (direct|endpoint), detail}— detail is the URL or SDK chain that produced the match.rollup_ref— OPTIONAL: event_id of thecomponent_changedrollup this event belongs to (walk it for the aggregated view).
provider_level_notices— breaking events for used-but-unlinked providers:{event_id, provider, severity, category, endpoint, detail}. Deliberately no file/line fields — that's the tier's whole meaning.unmatched_events—{count, by_severity}of events Analyze could not honestly attach to this codebase: the provider is absent from the scan report entirely, or a breaking event's provider evidence was below high confidence (amedium/lowentry is a clue, not proof of use — not enough to page someone). Count only, never itemized.not_assessable— info events, listed:{event_id, provider, category, detail}.summary—impacts_total,impacts_by_tier {direct, endpoint}(an impact with any direct row counts as direct),impacts_by_severity,provider_level_notices,unmatched_events,not_assessable,safe_unlinked.
Deterministic throughout: same scan report + same events → the same
Impact Report, byte for byte (modulo analyzed_at).
Known limitations (Analyze v1)
- Wrapper attribution is ONE HOP, same repo. A caller of a wrapper
resolves; a caller of a caller does not. Object-method wrappers
(onyx's
rl_requests.get(...)) attribute as of v2.5 — through MODULE-LEVEL exposures only; instances passed as arguments stay out of scope by design. Cross-file class-field inheritance is also out of scope. - Mapping beyond Stripe is generic-rule only (see the coverage table) — endpoint-tier claims for those providers are existence-guarded but not hand-validated.
- Wrapper modules break the chain: code that wraps the SDK
(
billing_utils.charge(...)callingstripe.Charge.createinternally) attributes impact to the wrapper's stripe call, not to the callers of the wrapper. Cross-file call-graph tracing is future work. - Python
StripeClientstyle (client.charges.create) maps via the generic walk; the classic module style is the hand-validated one. - No alerting: the Impact Report is a JSON file. Notification fan-out (Slack, PR comments) is Report's job — deliberately not built here.
The proxy honesty design (the most interesting decision here)
When code calls an API through a proxy — a library that forwards your request to whichever provider you configured at runtime — a scanner faces a temptation: guess the provider. Browniie refuses, and the refusal is the product. Three facts are kept strictly separate:
- A wrapper hop is a fact — follow it. If this repo's own
rl_requests.get("https://api.notion.com/...")wrapsrequests.get, attributing Notion to the caller is following code that exists. Allowed, at full confidence, with the hop recorded inviaso anyone can check it. - A proxy dependency is a fact; the provider behind it is not.
litellm.completion(model=<runtime value>)proves the repo depends on litellm as an LLM proxy. It does not prove which provider runs. So Browniie emits aproxiesrecord naming litellm and explicitly nothing else — "routes LLM calls through litellm here; the specific provider is chosen at runtime." That is honest and useful: a human who reads "you route through litellm and Anthropic shipped a breaking change" can check their own config in seconds. Analyze turns this into theproxy_reachabletier — a notice that names the proxy and its call sites, never a fabricated file/line for the provider. - A model-string LITERAL in source is evidence; a convention applied
to a runtime value is a guess.
DEFAULT_MODEL = "claude-sonnet-4-…"is a literal that exists — matching it against registrymodel_prefixesis the same kind of operation as matching a URL against a domain. Allowed, at a clearly-labeled lower tier (model_literals). Matching a model value that came from a variable, a config read, or a database row is forbidden, forever.
The rule underneath all three: never infer a provider from a value the
code does not contain. Any time a gap tempts a "it's probably X," the
answer is a proxy record or an honest unknown. On Onyx — where one
litellm.completion call carries 100% of chat traffic and Anthropic has
zero SDK footprint — this is what lets Browniie say something true and
useful (251 hardcoded Claude model names + a named litellm dependency)
instead of either staying silent or inventing a call site.
Report (SWARM step 5)
Report is the layer for humans who will never open a JSON file. It takes one Impact Report and says what matters, where you already look: a PR comment with the file/line table (the detailed channel), and a Slack digest with the counts and a link (the glanceable channel). Two channels exist on purpose — repeating the detail in Slack would just build a wall nobody reads.
How it decides what to say
One function, build_content(), turns an Impact Report into a
channel-agnostic content object; each channel only formats it. The
rules are law, carried forward from Analyze:
- Severity leads. Breaking impacts always come first; the headline is severity-led with strict precedence (breaking → provider notices → safe → all clear). Empty severities are omitted — never "0 breaking" noise.
- Never overclaim. Provider notices have no file/line because Analyze couldn't narrow them; no channel may invent one.
- Harmless collapses. Safe and informational changes render as ONE counted line. A release window ships hundreds of safe additions; itemizing them trains readers to skim past the breaking ones.
Content object — Report contract v1 (frozen)
headline— one severity-led line.sections— ordered{title, severity, rows}; severities present only:breakingrows:{file, line, provider, tier, detail}— one per affected code location.providerrows:{provider, count, detail}— one per provider,detailis the first event's text as a concrete example. No file/line, ever.inforows: exactly one —{count, safe_impacts, safe_unlinked, not_assessable}.
footer—{impact_report, analyzed_at, scan_repo, scanned_at, events_refs}: the trail back to the artifact. (Archive commits stay per-event in the Watch stream — one canonical pointer per fact.)
Deterministic: same Impact Report + same ref → byte-identical content.
The channels
PR comment (--pr owner/repo#123): headline bold; breaking impacts
as a table whose columns include the endpoint (GET /organization/audit_logs — the single most actionable fact), capped at
50 rows with an honest "Showing 50 of N" overflow line; notices one
bullet per provider with the example endpoint and a docs link from the
registry's docs_url; safe/info one human sentence; every section ends
with a one-line call to action; the footer names the bare repo only —
local filesystem paths never reach a comment.
Posting is idempotent: every comment ends with a hidden HTML marker
(<!-- browniie:impact:<hash> -->, invisible when rendered), and a later
run finds its own comment by that marker and UPDATES it instead of
stacking duplicates — one living Browniie comment per PR. The hash
identifies the scanned repo, so fresh reports supersede old ones.
(Mechanics worth knowing: comments post through GitHub's issues API —
for commenting purposes a PR is an issue.)
Slack digest (--slack-webhook-config-key KEY): Block Kit message —
headline, counts, link to the PR comment. Brevity is a tested property:
≤ 6 blocks and < 2,000 serialized characters even against the worst
observed real case (1,082 provider notices), asserted in the suite.
Report setup (one-time)
Secrets live in report_config.yaml at the repo root — gitignored,
never committed:
github_token: "github_pat_..."
slack_webhooks:
default: "https://hooks.slack.com/services/T000/B000/xxxx"
- GitHub token: GitHub → Settings → Developer settings → Fine-grained tokens → Generate new token → Repository access: only the repo(s) Browniie will comment on → Permissions: Issues: Read and write and Pull requests: Read and write. Least privilege, same reasoning as the archive token.
- Slack webhook: create an incoming webhook for the channel you want the digest in — follow Slack's own guide at https://api.slack.com/messaging/webhooks (it walks through creating the app and copying the URL; there is nothing Browniie-specific about it).
You now have the complete pipeline
What a real week looks like:
- Daily, automatically: the GitHub Actions workflow runs a watch cycle — every provider spec/changelog fetched, archived, replicated, diffed; events land as artifacts.
- When you care about a repo (once, or on each PR): scan it —
run_scan.py <repo> -o reports/repo.json. - When Watch reports changes (the run summary's
events_total> 0): join them —run_analyze.py reports/repo.json reports/watch/<run>.events.jsonl— and read the impact summary it prints. - Deliver:
run_report.py reports/analyze/repo.impact.json --pr owner/repo#N --slack-webhook-config-key default. The PR gets the file/line table; Slack gets the one-liner; re-running updates in place. - When something breaks upstream, the PR comment is already pointing at the exact lines to fix. (Fixing them automatically is Mend — the one SWARM letter still unbuilt.)
Known limitations (Report v1)
- Delivery is on-demand: nothing schedules analyze→report runs yet; the daily workflow only runs Watch. Wiring scan→analyze→report into CI per subject repo is the obvious next ops step.
- One comment per scanned repo per PR — by design (idempotency), but it means two different Impact Reports for the same scanned repo overwrite each other on the same PR.
- No retries/rate-limit handling on delivery: a failed POST is a loud error for the caller to re-run.
Mend (SWARM step 5) — the deterministic fixer
What Mend is, for a beginner
Watch saw a provider change its API. Analyze proved your code is affected, with file and line. Mend turns that into a draft pull request in your repository — a version bump where that is provably safe, review annotations where it is not, and an explanation a reviewer can act on without opening another tool. It is deterministic end to end: no LLM, no judgment calls — a version bump plus an annotation quoting an archived spec fragment is mechanical assembly.
The governing law: Mend never edits a line it cannot trace to a fact. Every planned action cites the Watch event id that motivated it and the Scan record that located it. An action that cannot name both cannot exist (asserted in code, refused at validation).
The three tiers, with concrete examples
- BUMP-SAFE (may produce a file edit): your
requirements.txtpinsstripe==5.4.0, Scan located that pin, yourinvoices.py:9calls the removed endpoint directly, and the Watch event is a confirmed breaking change. The edit is the version token of that pin — nothing else — plus a review annotation above the affected call. Only EXACT pins are bumped:stripe>=5.0is not a pin, and rewriting a range would change resolver behavior you chose. - ANNOTATE-ONLY (a comment, never an edit): a config-tier row (the
URL is known to your codebase; no call is proven), a
proxy_reachableormodel_literalorframework_httprow, any wrapper chain two or more hops deep (a -> b— the honest edit point is the wrapper, which the row does not locate), and anyurl_linesplit (the URL fact lives on a different line than the call — the annotation goes to the FACT line). - NEVER-TOUCH (no edit, no annotation): anything reached only
through a proxy (litellm routes to a runtime-chosen provider), a
templated host (
{{ tenant }}.salesforce.com— resolved by config, not by this code), an unknown, or a language Scan does not analyze.
The four kinds of draft PR content you will see (v25)
At suggest tier and above, one draft PR carries everything Browniie has for your repo, and each row says plainly which kind it is:
- A deterministic fix — a rule, an admitted recipe, or an admitted migration map wrote the diff; no model was involved and nothing left your machine. Byte-identical on every run. The only kind tier-3 auto may apply.
- A partial fix with holes — everything provable is edited, and
each genuinely undecidable value is the literal token
__BROWNIIE_HOLE__<name>: it parses, so the diff is reviewable, and it cannot run, so it cannot merge quietly. You fill the blank; the PR names why it is a blank. - A model-drafted fix — the deterministic engine refused, and a model drafted the change for YOUR review under every structural guard. The body names the model, quotes the shape's measured evidence (including the wrong rate that keeps it off the auto-eligible list, when it is off), and carries the five-point reviewer checklist. Never auto-applied, at any setting.
- An annotation — the honest floor: the provider's own guidance quoted at your affected line, for the cases where the product was retired outright, the value is yours alone to choose, or the model honestly declined.
The coverage floor (repo-level honesty)
If more than 30% of a repo's recognized source is unanalyzed, the ENTIRE proposal downgrades to annotate-only and the PR body states what was and was not covered. Rationale, from the measured corpus: pre-Ruby Discourse scanned 78% blind and pre-Go Grafana 41% — a "here is your API exposure, fixed" PR on such a repo would be true but misleading, the exact failure this project's doctrine exists to prevent. (Airbyte today sits at 44.8% — its connector CDK is Kotlin — and Mend correctly refuses to bump it.) Disclosure is unconditional: the coverage statement appears in every PR body, floor or no floor.
Sandbox verification — a bump is a claim, so it is checked
Before a bump ships, Mend copies your repo to a temporary directory
(.git excluded), applies the bump THERE, and runs your repo's own
test command (from facts your files state: package.json scripts,
Gemfile + spec/, go.mod, pom.xml, pytest config, or unittest
files) inside a container jail:
--network=none, always. A test suite that genuinely needs the network fails closed and the row downgrades — exactly like a red test result. There is deliberately NO opt-in network flag in v1 (a capability that exists mainly to be left on).- Non-root user, read-only image filesystem, a bounded tmpfs
/tmp, and CPU/memory/PID limits plus a wall-clock timeout enforced from outside (the container is killed by name on expiry). A fork bomb in someone's test suite must not take down the host. - No host secrets reachable: the container inherits no environment variables, and the only mount is the throwaway repo copy — never your home directory, SSH keys, Docker socket, or Browniie's config. This is tested by planting a fake secret in the host environment and asserting, from inside the jail, that it never arrived.
- No host fallback exists. If no container runtime (docker/podman) is available, verification reports itself unavailable and every bump downgrades to annotate-only, stated in the PR body. Falling back to host execution would be the exact silent-danger pattern this project forbids, so the capability is absent, not disabled.
Green -> the bump stays, and the PR body records what verified it (command + "containerized, network=none"). Red, no test command, no runtime, or timeout -> the bump is removed and the row downgrades, stating plainly what happened. A failed verification in a PR body is not an error — it is Mend refusing to imply something was checked when it wasn't.
Supply chain (v13): sandbox images run BY DIGEST, never by tag —
sandbox_images.yaml records the manifest-list digest for each
image, and an entry without a digest makes verification unavailable
rather than pulling a mutable tag. The repo copy is bound READ-ONLY
at /src; the suite runs in a memory-backed tmpfs the startup shim
populates, so nothing the sandbox writes ever touches durable
storage, and the tmpfs sizes (1 GB work, 256 MB tmp) are hard disk
quotas.
Honest limits: a container is strong isolation, not a security boundary against a determined attacker — kernel escapes exist, and a digest pin proves you run the bytes you recorded, not that those bytes are benign. The copy still guarantees your working tree is never written (byte-compared in tests). Run Mend only against repositories whose tests you would run by hand.
The draft-PR-only guarantee
Every Mend output is a draft pull request on a dedicated branch
(browniie/mend-<subject-hash> — deterministic, no random suffixes).
Re-running updates the same branch and PR (the Report layer's
subject-scoped marker, embedded last in the body). There is no
auto-merge, no push to a default branch, no force-push, and no
review-approval capability in this codebase at all — the capability
is absent, not disabled, so no flag or bug can enable it. A human
reviews, edits, and merges — or closes.
Privacy — the property a security reviewer should check first
No customer source code ever leaves the machine: no LLM, no telemetry, deterministic local analysis. Every artifact Browniie emits — scan reports, impact reports, Mend plans, PR bodies — carries metadata about code, never code contents: file paths, line numbers, dotted call chains, provider names, API hosts, event ids, version strings. Source lines, config values, and secrets are not metadata and never appear. The single deliberate exception is the diff inside the pull request itself — a diff necessarily contains the changed lines, and it lives only in your own repository.
This is TESTED, not intended: a fixture repo plants a fake credential
directly beside a matched URL and beside a manifest pin, and the
suite asserts the credential appears in no plan, no PR body, no
title, no branch name, no annotation. URLs are query-stripped
everywhere (?key=... never survives — the extract_url_token
precedent extended to Mend); absolute local paths never appear (the
Report v1 leak, re-tested here); sandbox test OUTPUT (which can quote
source) is never recorded — only the command label and exit result.
Running Mend
# 1. Plan (zero writes; plan lands in gitignored reports/mend/)
.venv/bin/python run_mend.py plan reports/analyze/myrepo.impact.json \
reports/myrepo.json --repo /path/to/repo \
[--bump-target stripe=7.0.0]
# 2-4 (render to disk, sandbox-verify, open the ONE draft PR) are
# exercised by the test suite and the dogfood run; the draft-PR opener
# requires a GitHub token with contents:write + pull_requests:write on
# the TARGET repo only (a fine-grained PAT scoped to that repo), read
# from report_config.yaml (gitignored) exactly like Report's token.
Where the bump version comes from — what Mend can and cannot know.
Without --bump-target, Mend derives a candidate from facts: the
package registry's own release dates (PyPI/npm/RubyGems/Go
proxy/Maven Central), taking the earliest STABLE release published on
or after the Watch event's detection time — the first release that
could have shipped against the changed API. The plan and PR body
record this as bump_target_source, so a reviewer sees exactly why
that number was chosen. It is a deliberately weak mapping, stated as
such: Mend verifies your test suite passes with the new version; it
cannot verify the version resolves the specific breaking change —
registries publish dates, not intent. Where no stable release
postdates the event (or the lookup fails), the row downgrades:
"target version must be supplied; no source-backed mapping
available." --bump-target still works and is recorded as "supplied
by operator". An unsourced version never reaches an edit silently.
What Mend cannot fix (v1 honest limitations)
- Anything requiring judgment about a REPLACEMENT — a removed endpoint's successor, a renamed field's new name — is out of scope for a deterministic v1. Mend tells the reviewer what changed upstream (quoting the archived spec fragment, verifiable via its archive commit) and where the code touches it; choosing the rewrite is human work.
- Call-site rewrites are never attempted, at any tier.
- Wrapper DEFINITIONS are not edited: a deep-via row annotates and explains rather than guessing at the wrapper's location.
- Range specifiers, lockfiles, and unpinned dependencies are not bumped — only exact pins whose anchor Mend re-validated against the repo at render time.
Ops — what runs where, daily
- Scheduler:
.github/workflows/watch.ymlruns one full watch cycle every day at 06:17 UTC (and on demand via the Actions tab → watch → Run workflow). The workflow file is commented line by line — it is also the teaching document for GitHub Actions. - Data: raw snapshots go to the archive repo
(
browniie-archive, pushed by the cycle itself viawatch_config.yaml'sarchive_replication); events + run summaries are uploaded as 90-day workflow artifacts — deterministic derivatives, re-derivable from the archive forever. - Checking on it: the Actions tab shows every run; a failed run emails the repo owner (that IS v1 alerting, by design). The run summary prints fetch/change/event counts and the archive-push outcome.
- Recovering the archive on a new machine:
git clone git@github.com:<user>/browniie-archive.git archiveinside the project — the nextrun_watch.pypicks it up exactly where it left off (state lives in the archive, not the machine). - The guard: the workflow refuses to run if replication is disabled — an unpushed CI archive commit dies with the runner, and green-but- discarding would be the worst failure this codebase can have.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file browniie-27.0.0.tar.gz.
File metadata
- Download URL: browniie-27.0.0.tar.gz
- Upload date:
- Size: 502.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ab4c3ed056770c386e1776a53e1880cb613c8b104043f7d2ec5ac372c80ed2f2
|
|
| MD5 |
6509926d9fd6aededb307ea6e107caed
|
|
| BLAKE2b-256 |
b4630b96bd9b58ee4ec8b9d0acfd349a3160293ee5dd905056aeb623b2502769
|
File details
Details for the file browniie-27.0.0-py3-none-any.whl.
File metadata
- Download URL: browniie-27.0.0-py3-none-any.whl
- Upload date:
- Size: 466.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
65ceba2b139a0819581922fbde9ea64f2c4c4e210e5c11d59ee9197245eb4362
|
|
| MD5 |
7aab158d2236569f5d5096de185eed77
|
|
| BLAKE2b-256 |
8eb8cdbebccc135e82454e863d0b807159ac1a7ce463dd29823ab0db590b6fac
|