+404
-0
@@ -8,2 +8,406 @@ # Changelog | ||
| ## [0.5.8] - 2026-08-13 | ||
| ### Added | ||
| - **`scan` now reports the repairs it can actually perform, not only the change | ||
| records it matched.** The scanner joins the upstream change feed against your | ||
| code; the fixer carries a separate corpus of 47 migration packs. The two only | ||
| overlap where a pack declares change ids the feed also holds — and the three | ||
| SDK-major packs (openai v3→v4, stripe v7→v8, aws-sdk v2→v3) declare none. A | ||
| repo could therefore be fully broken by one of them and be told it was clean: | ||
| measured on openai/openai-quickstart-node at its `openai ^3.1.0` commit, | ||
| `fix --migration openai-v3-to-v4` rewrote a file that `scan` never named among | ||
| its seven findings. Reports now carry `repairs_available[]` | ||
| (`{ pack, provider, title, reference, files[], rules[] }`) and the terminal | ||
| output prints a "repairs available" section with the exact `fix --migration` | ||
| command per pack. Membership is proved by replaying each pack over the repo's | ||
| bytes and keeping only packs whose rewrite changes something — not by a | ||
| `detect` pre-filter, which claimed a cloudflare path-param migration on a | ||
| plain `stripe.customers.list()` call. The section renders on the zero-impact | ||
| path too, which is where the gap did its damage. | ||
| - **Each offered repair now states how much of the report it retires.** | ||
| `repairs_available[]` entries carry `mends_impacts[]` — the change ids from | ||
| the same report that the pack's authored coverage claims — plus | ||
| `declares_coverage`, which is `false` for the three SDK-major packs that make | ||
| no claim about the feed at all. The terminal block prints | ||
| `mends N of the M impacts above`, or names the honest alternative when a pack | ||
| repairs code the feed never flagged. Previously a report could print | ||
| "1762 potential impacts" and "1 repair available" with nothing connecting | ||
| them, leaving the reader unable to tell whether running the repair addressed | ||
| the findings or something unrelated. Measured on a repo reading | ||
| `payment_method_options.us_bank_account.preferred_settlement_speed`: the one | ||
| offered pack retires 40 of the 1762 findings, including all 6 high-confidence | ||
| ones. | ||
| - **A pack that can never join the feed now says so, instead of going silent.** | ||
| The three SDK-major packs (openai v3→v4, stripe v7→v8, aws-sdk v2→v3) declare | ||
| no coverage for a structural reason, not an editorial one: they migrate code | ||
| to a version older than the oldest release the feed holds for that repo — | ||
| measured, the feed's earliest openai/openai-node release is v6.40.0, stripe/ | ||
| stripe-node v22.2.3, aws/aws-sdk-js-v3 v3.1086.0, against pack targets of | ||
| v4.0.0 / v8.0.0 / v3.0.0. Until now the repair block printed nothing at all | ||
| for them, which reads identically to "this repair is unrelated to your | ||
| findings". Entries now carry `coverage_boundary` | ||
| (`{ repo, to_version, feed_oldest, predates_feed }`), recomputed from the | ||
| changes table at scan time rather than asserted by the pack, and the terminal | ||
| block explains the gap as a corpus limit. Packs that do declare coverage are | ||
| unaffected and print their `mends` line as before. | ||
| ### Changed | ||
| - **A specification format name is no longer treated as a named API surface.** | ||
| `OpenAPI`, `GraphQL`, `OpenTelemetry` and `WebMCP` pass the PascalCase branch | ||
| of the substantive-symbol test, so a generated-SDK release whose entire body is | ||
| "regenerated from the OpenAPI spec" scored as having named a surface — the | ||
| strongest half of the high-confidence signal — on a sentence that names no | ||
| field, no method and no endpoint. Measured across the corpus: 17 records rest | ||
| on nothing else, 4 of them breaking or deprecation. The filter is an exact list | ||
| rather than a shape rule, and that choice is load-bearing: the highest-frequency | ||
| PascalCase tokens in the corpus are real Twilio path and query parameters | ||
| (`AccountSid`, `ServiceSid`, `PageToken`), and any heuristic broad enough to | ||
| catch `OpenAPI` by shape deletes them. Confidence band only — the token stays in | ||
| `matched_symbols` and in `--json`. | ||
| - **A dotted token lifted out of a link is no longer treated as a named API | ||
| surface.** The symbol extractor is deliberately greedy, so | ||
| `https://docs.slack.dev/changelog` yields the "member path" `docs.slack.dev`, | ||
| which passes the substantive-shape test and counts as "the release named a | ||
| surface". The obvious fix — blacklisting hostname-shaped suffixes — was | ||
| measured against the whole 84112-record corpus and rejected: 105 distinct | ||
| substantive symbols end in a TLD-shaped segment and nearly all are real | ||
| fields (`payment_method.id`, `payment_method.link`, `creator.via.app`). The | ||
| discriminator shipped instead is provenance: a token is discounted only when | ||
| *every* occurrence in the record sits inside an http(s) URL, so a field named | ||
| in both a docs link and a sentence still counts. Corpus-wide this touches 5 | ||
| records and 12 tokens. Confidence band only — the token stays in | ||
| `matched_symbols` and in `--json`. | ||
| - **`review --llm` now asks one question per repair, not one per change record.** | ||
| A spec is versioned per endpoint, so a single upstream edit is filed once for | ||
| every endpoint that embeds the changed object. Measured on | ||
| stripe-samples/accept-a-payment: 214 medium-confidence findings that are 41 | ||
| distinct repairs, the four largest being 25/23/23/23 records of one property | ||
| removal. Every one of those was previously sent to the LLM as its own request, | ||
| so users paid for 214 completions to answer 41 questions — and the same repair | ||
| could come back confirmed on one endpoint and unlikely on the next. The review | ||
| now groups by repair (scoped per provider), asks the highest-ranked member, and | ||
| applies that verdict to the whole family: 41 calls instead of 214, with all 214 | ||
| findings still receiving their own audit entry. `review --pending` gains a | ||
| `repair_family` field but is deliberately NOT collapsed — it is a published | ||
| output contract, and dropping entries would break existing parsers. | ||
| - **The published repo-size ceiling for the "30 seconds" promise drops from | ||
| ~3,000 to ~2,000 files.** The ceiling was measured on an idle machine. It is | ||
| now measured with every core busy, which is the case a user actually hits on a | ||
| working laptop or a shared CI runner: there the 30-second budget is exhausted | ||
| at ~2,051-2,347 files, below the number we were publishing. The claim was | ||
| outrunning the measurement in exactly the condition where a first-time user | ||
| would notice. The README, home page, how-it-works page and docs index now | ||
| state the lower bound, which still sits below the lowest loaded observation | ||
| rather than at its average. | ||
| ### Fixed | ||
| - **Reading `process.env` no longer counts as using an SDK's changed surface.** | ||
| A release is promoted to the top of the report when it names a concrete | ||
| surface and your code matches one. That test was shape-based — a dotted member | ||
| path, a PascalCase type, a snake_case field — and JavaScript platform builtins | ||
| satisfy those shapes exactly. Measured on cloudflare/templates (422 files): | ||
| all three high-confidence findings were cloudflare-typescript majors resting on | ||
| nothing but `process.env` (env-var reads in a deploy script) and `RequestInit` | ||
| (a type annotation in a test helper), while every concrete surface those | ||
| releases name went unmatched. Language and runtime globals now carry no surface | ||
| information. The tokens still appear in `matched_symbols` and in `--json`; they | ||
| simply stop promoting a finding on their own. Provider surfaces are untouched, | ||
| including builtin-adjacent names like `RequestOptions`, and eleven previously | ||
| measured real repos rescan byte-for-byte identically. | ||
| - **Paths that are not spelled in lowercase are no longer truncated before | ||
| matching.** Several APIs write resources in PascalCase and version some paths | ||
| by date: Twilio's spec names `/v1/LinkShortening/Domains/{DomainSid}/Config` | ||
| and `/2010-04-01/Accounts/{AccountSid}/Calls/FeedbackSummary.json`. The path | ||
| extractor accepted lowercase segments only, so those paths were cut down to | ||
| their first lowercase run (`/v1/a2p`) before any evidence check ran — the | ||
| surface a change actually named never reached the matcher. Date-stamped | ||
| version prefixes are now dropped like `/v1/`, response format suffixes | ||
| (`.json`) are not treated as part of a resource name, and the SDK's own | ||
| lowerCamel spelling (`client.linkShortening.domains(...).config()`) counts as | ||
| usage evidence, including when instance-id calls sit between chain links. A | ||
| nested call between links still does not match, so the chain cannot drift. | ||
| - **Endpoint-level changes are now evidenced through SDK calls, not only literal | ||
| URLs.** A spec-diff change names a request path (`/v1/checkout/sessions/{session}`), | ||
| but a repo using the vendor SDK never writes that string — it writes | ||
| `stripe.checkout.sessions.create(...)`. Requiring the literal path therefore | ||
| proved only that a repo speaks raw HTTP, and measured on two real public repos | ||
| that both import the SDK, zero of 1,747 and zero of 1,755 endpoint-level | ||
| changes carried any evidence: every one was silently floored to low confidence | ||
| whatever surface it touched. The scanner now also reads the change path's | ||
| resource chain as member access, in wire spelling (`payment_intents`) or the | ||
| SDK's camelCase (`paymentIntents`), and reports the matching file and line. | ||
| Deliberately narrow: source files only, code lines only, the chain must be | ||
| reached through a member access rather than a bare identifier, and a | ||
| single-word generic resource never carries a finding on its own. | ||
| - **A symbol that only appears in a comment no longer counts as usage.** A | ||
| changed API surface mentioned in a doc comment — `// at | ||
| https://dashboard.stripe.com/settings/payment_methods.` — was matched exactly | ||
| like a call site, promoting the finding to high confidence and pointing the | ||
| reader at a line with no code on it. Measured against public sample repos: of | ||
| 222 high-confidence findings in `stripe-samples/accept-a-payment`, 68 rested | ||
| entirely on comment lines. Those findings now fall back to the weaker | ||
| import-level evidence, and code lines are cited before comments for the ones | ||
| that survive. A symbol used in real code keeps its high verdict even when a | ||
| nearby comment also mentions it. | ||
| - **A pipenv repo no longer scans as having no dependencies at all.** `Pipfile` | ||
| is the fourth place a Python project declares its dependencies, and the only | ||
| one of the four with no file extension — so it was skipped before matching | ||
| ever began, at the point where the walker decides whether a file is worth | ||
| reading. A repo whose sole declaration was `[packages]` with | ||
| `stripe = ">=11.0.0"` scanned as zero files, zero providers and zero impacts: | ||
| the report said the code was unaffected for a project that installs the SDK | ||
| on every `pipenv install`. Pins under `[packages]` and `[dev-packages]` are | ||
| now read, in the plain, inline-table and quoted-key spellings, and attributed | ||
| to the Python ecosystem so the declared-range gates apply to them. | ||
| `Pipfile.lock` is deliberately still ignored: it is generated output whose | ||
| keys mirror the `Pipfile`, so reading both would count the same declaration | ||
| twice. | ||
| - **A tilde range is now read in the ecosystem's own grammar.** RubyGems' | ||
| pessimistic `~> 7.0`, PEP 440's `~= 7.0` and Composer's `~14.2` all free the | ||
| component above the last one stated, while npm's `~14.2.0` frees only the | ||
| patch level — a difference of a whole major version. Every one of them was | ||
| read as npm, so `gem 'twilio-ruby', '~> 7.0'`, the most common way a Gemfile | ||
| pins an SDK, got a 7.1.0 ceiling instead of 8.0.0. Measured on | ||
| `stripe~=15.3` against the stripe-python corpus: 6 real breaking releases, | ||
| including the stable v15.4.0, were demoted to low and flagged out of the | ||
| declared range — reported to the user as "your code is unaffected" for | ||
| releases the repo installs by default, with the report otherwise looking | ||
| perfectly normal. npm's own reading is unchanged. | ||
| - **A dependency declared twice no longer inherits the stricter declaration's | ||
| version range.** A repo that lists the SDK in both `dependencies` and | ||
| `devDependencies` — one open (`">=1.0.0"`, a wildcard, an alias), one capped | ||
| (`"^1.0.0"`) — can install whatever the open declaration admits. The bound | ||
| readers dropped declarations that stated no bound, so the capped sibling | ||
| decided the whole ecosystem on its own: measured on that pair, 7 real findings | ||
| were demoted to low and flagged out-of-range, identical to a repo that really | ||
| is pinned, while the same repo with only the open declaration reported them. | ||
| A declaration stating no bound is now the most permissive one and clears its | ||
| ecosystem outright, on both the floor and the ceiling side. A passing mention | ||
| of the package name in prose (`"name": "my-stripe-app"`, a package blurb) is | ||
| not a declaration and still cannot clear a genuine cap. | ||
| - **An aliased dependency no longer inherits its alias target's version range.** | ||
| An alias names the package twice — `"stripe": "npm:@scope/stripe@^14.0.0"` — | ||
| and the second name sits inside the value. The version read began at the last | ||
| occurrence of the name, so it started past the `npm:` prefix and the check for | ||
| values that resolve elsewhere never saw the protocol: the alias target's range | ||
| was reported as this package's own declared cap, muting 7 real findings to low | ||
| and flagging them out-of-range. The read now starts at the last occurrence | ||
| that actually begins a declaration, which leaves aliased and `git:`/`file:` | ||
| values recognised as stating no version at this site while keeping a | ||
| minified manifest resolved to its own entry rather than a neighbour's. | ||
| - **A comment beside a dependency is no longer read as part of its version | ||
| range.** Every manifest grammar here allows a trailing comment on the | ||
| declaration line, and authors routinely put version numbers in them. Both | ||
| bound readers scanned the whole line, so the prose became the constraint: | ||
| `stripe>=1.0.0 # do not go <2.0.0 yet` was capped at a 2.0.0 the file never | ||
| states, muting 8 real findings to low and flagging them out-of-range, and | ||
| `stripe # pinned >=9.0.0 in prod` invented a floor on a declaration that | ||
| states none. Comments are now stripped (`#` in requirements.txt, setup.cfg, | ||
| Gemfile and TOML; `//` in go.mod) after the check for values that resolve | ||
| elsewhere, so a `#egg=` fragment or an `https://` URL is still recognised | ||
| rather than truncated. A real bound written next to a comment still wins. | ||
| - **A dependency that excludes one version is no longer treated as pinned to | ||
| it.** `!=` rules a single release out; it states no upper bound. That operator | ||
| was missing from the ceiling reader, so `stripe!=5.0.0` fell through to the | ||
| exact-pin branch and was capped at 5.0.0 — the same verdict as `stripe==5.0.0`. | ||
| Measured on a repo with that one line, 8 real findings were muted to low and | ||
| flagged out-of-range while the report still looked normal. Floor reading has | ||
| always refused `!=`; only the ceiling side read an exclusion as a cap. | ||
| - **A `pyproject.toml` dependency no longer inherits the next entry's version | ||
| range.** PEP 621 states each dependency as one quoted string holding the name | ||
| and the range together (`dependencies = ["stripe>=11.0.0", "zod<3.0.0"]`). | ||
| That value was read past its own closing quote, so stripe took zod's | ||
| `<3.0.0` as its ceiling and every real finding above 3.0.0 was silently | ||
| dropped to low. A bare entry (`["stripe", "zod>=3.0.0"]`) was worse: with no | ||
| range of its own it took the *next array element* as its constraint. Both now | ||
| stop at the entry's own closing quote. | ||
| - **A range union written in descending order no longer mutes releases the repo | ||
| can install.** `"stripe": "^15 || ^14"` admits all of 14.x, but the floor was | ||
| read from the first alternative only, so 14.x findings were dropped to low — | ||
| while the identical `"^14 || ^15"` reported them. The floor is now the lowest | ||
| alternative the union states, and a union containing an open-ended | ||
| alternative states no floor at all. | ||
| - **A version range spelled across more than one token is now read whole.** Two | ||
| grammars state a range without an operator in front of every bound, and both | ||
| were misread in the false-negative direction. npm's hyphen range | ||
| (`"stripe": "14.0.0 - 24.0.0"`) has no leading operator at all, so it fell | ||
| through to the exact-pin branch and the range's *floor* came back as an | ||
| inclusive ceiling — every finding in a range the repo genuinely installs was | ||
| muted to low, while the identical `>=14.0.0 <24.0.0` reported normally. | ||
| Bundler's multi-argument form (`gem 'twilio-ruby', '>= 6.0', '< 7.0'`) read | ||
| only the first constraint and threw the upper bound away. Both now parse to | ||
| the bounds they state; option arguments (`require: false`, `group: 'x'`, | ||
| `:source => '...'`) are never absorbed as a version bound. | ||
| - **A neighbouring dependency's version range no longer bleeds into yours.** The | ||
| declared-range gates anchored their read at the provider's own name but never | ||
| bounded where that entry ends, so a minified manifest — | ||
| `{"stripe":">=14.0.0","zod":"<3.0.0"}` — handed stripe the neighbour's `<3.0.0` | ||
| ceiling and silently dropped every real high-confidence stripe finding to low. | ||
| This is the false-negative direction: the report told you your code was fine | ||
| because of an unrelated line. Each manifest grammar now reads only its own | ||
| value (JSON quoted values, `gem` tuples, TOML plain and inline-table values, | ||
| one-entry-per-line text formats), and a value that is an object or a protocol | ||
| (`catalog:`, `latest`) states no version rather than borrowing the next one's. | ||
| - **An SDK release ABOVE the version range your manifest declares no longer | ||
| reports as a high-confidence impact either.** The floor was closed first; this | ||
| is the other half, and it is the half a first scan is more likely to hit. A | ||
| repo pinning `"stripe": "^14.0.0"` was shown stripe-node v22.3.1 at high | ||
| confidence, at the very top of the report — a major it cannot install without | ||
| changing the pin. Caret, tilde, pessimistic (`~>`), compatible-release (`~=`), | ||
| explicit `<`/`<=` bounds and exact pins now all state a ceiling, and a release | ||
| above it is capped to low and flagged `below_declared_range` in `--json`. | ||
| Anything that states no usable ceiling — a floor-only pin, a range union | ||
| (`^14 || ^15`), a wildcard, a `catalog:`/`workspace:`/git reference, or a | ||
| go.mod `require` line, which states a minimum rather than a bound — leaves | ||
| confidence untouched, so the fail-safe direction remains "report it". | ||
| - **Both version gates now read a `setup.py` declaration.** `setup.py` is a | ||
| dependency declaration site that is deliberately not treated as a manifest | ||
| (it is also Python source), so a setuptools project declaring its pins only | ||
| there had no declared range at all and every release stayed promotable however | ||
| far outside that range it was. | ||
| - **An SDK release older than the version range your manifest declares no longer | ||
| reports as a high-confidence impact.** Every manifest matcher already proved | ||
| the value it read had the shape of a version range, then discarded the value. | ||
| So a repo pinning `"stripe": "^22.9.0"` was still told that stripe-node | ||
| v22.3.1 breaks it — a release it cannot install, ranked at the top of the | ||
| report because its symbols genuinely matched. Declared floors are now read per | ||
| ecosystem across every manifest that declares the provider (the lowest floor | ||
| wins, since that is what the repo may install), and a release strictly below | ||
| that floor is capped to low confidence and flagged `below_declared_range` in | ||
| `--json`. Values that resolve elsewhere (`catalog:`, `workspace:`, a git URL), | ||
| upper-bound-only constraints (`<23`), and PEP 508 environment markers state no | ||
| floor and change nothing, so an unreadable declaration still reports the | ||
| change rather than hiding it. | ||
| - **A dependency nested one level below the section that declares it is no | ||
| longer invisible.** Scoping the manifest matchers to dependency sections left | ||
| three spellings unreadable, each of which removed the provider from the report | ||
| entirely rather than lowering its confidence: pnpm's scoped override form | ||
| (`"overrides": { "foo>bar": { "stripe": "1.2.3" } }`), PEP 621 extras | ||
| (`[project.optional-dependencies]` with `dev = ["stripe>=11"]`), and PEP 735 | ||
| dependency groups (`[dependency-groups]`). The `package.json` section walk can | ||
| now descend into nested objects while still being unable to climb out of the | ||
| section it started in, and the pyproject matcher scopes extras and groups by | ||
| their table header. Every false-positive control from the previous fix stays | ||
| at zero, including a same-named `config` key nested inside an object. | ||
| - **A key that merely shares a name with an SDK no longer invents a whole | ||
| provider.** The `package.json` and `pyproject.toml` matchers read the name | ||
| anywhere in the file, so `"config": { "stripe": "^14.0.0" }` — a setting that | ||
| installs nothing — reported stripe as a detected provider with its full | ||
| impact set, on a repo that does not depend on stripe at all. Both are now | ||
| scoped to the sections that actually declare a dependency: for | ||
| `package.json`, `dependencies` / `devDependencies` / `peerDependencies` / | ||
| `optionalDependencies` / `resolutions` / `overrides`; for `pyproject.toml`, | ||
| a `dependencies = [...]` list or an open Poetry/PEP 621 dependency table. | ||
| Real declarations in every one of those spellings still resolve unchanged. | ||
| This is the mirror image of the packaging-metadata misses below — a first | ||
| scan that opens with impacts nobody owns costs a user's trust faster than one | ||
| that reports too few. | ||
| - **A dependency declared only in packaging metadata is no longer invisible.** | ||
| A library repo often spells its runtime dependencies exactly once, and not in | ||
| the manifest the scanner knew about: a gem declares them in `<name>.gemspec` | ||
| (its Gemfile is then a one-line `gemspec` directive naming nothing), and a | ||
| setuptools project that predates pyproject declares them under | ||
| `[options] install_requires` in `setup.cfg` or in `install_requires=[...]` | ||
| inside `setup.py`. Each of those repos scanned as `providers_detected: []` — | ||
| the whole provider left the report rather than being reported at lower | ||
| confidence, so the scan read as "your code is unaffected". All three | ||
| declaration sites are now read. `setup.py` keeps being scanned as ordinary | ||
| Python source at the same time, so a setup.py that also imports the SDK | ||
| yields both findings. Precision is unchanged: a commented-out declaration | ||
| (what an upgrade leaves behind) declares nothing, development-only | ||
| dependencies (`add_development_dependency`, `extras_require`, | ||
| `tests_require`) are not product SDK calls and are excluded, the | ||
| distribution name in `setup()`'s own metadata is not a dependency, and | ||
| `stripe-mock` still does not count as `stripe`. | ||
| - **A dependency declared in `requirements.txt` or `pyproject.toml` is no | ||
| longer invisible.** Python's manifests are the same blind spot as the | ||
| Gemfile and `package.json` one ecosystem over: a service that pins | ||
| `stripe==11.4.0` and reaches the SDK through a wrapper reported | ||
| `providers_detected: []`, so every breaking change for that provider was | ||
| dropped rather than reported at lower confidence. Both manifests are now | ||
| read, in all the spellings that declare a dependency — a requirements line | ||
| with extras and any specifier, a PEP 621 `dependencies = [...]` entry, and a | ||
| Poetry `name = "^1.2"` table key — and distribution names are compared per | ||
| PEP 503, so `slack_sdk` and `slack-sdk` are recognised as the same project. | ||
| Precision is unchanged: a commented-out pin (what an upgrade leaves behind) | ||
| declares nothing, `stripe-mock` does not count as `stripe`, and the manifest | ||
| matchers never read ordinary source files. | ||
| - **A dependency declared in `package.json` but only used through a wrapper is | ||
| no longer invisible.** `"stripe": "^14.0.0"` in a manifest is the strongest | ||
| proof a repo depends on an SDK, but the scanner only read files with a source | ||
| extension, so a service that declares its dependencies and touches them | ||
| through an internal wrapper module reported `providers_detected: []` — every | ||
| breaking change for that provider was dropped, not downgraded. The Gemfile | ||
| had already been fixed; npm's manifest is the far more common spelling of the | ||
| same defect. Only real dependency entries count: a `scripts` entry with a | ||
| matching name declares no dependency and is ignored, and the manifest matcher | ||
| never reads ordinary source files. | ||
| - **A Python or Ruby import wrapped across lines with a backslash is no longer | ||
| invisible.** A backslash immediately before a newline is the idiomatic way to | ||
| wrap a long `import a, b, c` list, and it is how Ruby continues a long | ||
| `require`. A backslash is not a whitespace character, so the `\s` separator in | ||
| the import matchers stopped dead at it: in `import os, \`-newline-`openai`, | ||
| every module past the wrap was never seen. The consequence is not a | ||
| lower-confidence verdict — the provider drops out of the report entirely, and | ||
| the scan reads as "your code is unaffected". This is the same silent | ||
| underreport family as the multi-name list fix in 0.5.7, one syntax layer down: | ||
| that one lost modules in a later position on a single line, this one loses | ||
| every module past a wrapped line. The Python and Ruby import matchers now | ||
| treat a line continuation as a legal separator wherever an import statement may | ||
| be wrapped. Precision is unchanged: a continuation cannot make a lookalike name | ||
| (`openai_helper`) into an import, and a continued import inside a comment stays | ||
| silent. | ||
| - **A Ruby `require('gem')` written with parentheses is no longer invisible.** | ||
| `require` is an ordinary method in Ruby, so the parenthesised call is as valid | ||
| as `require 'gem'`, and generated or vendored code writes it that way. The | ||
| Ruby matcher only accepted the whitespace spelling. Gems whose name happens to | ||
| equal the npm package name (stripe, openai, plaid) appeared to work, but only | ||
| by accident — the language-agnostic JavaScript `require(...)` alternative | ||
| caught them. The four gems named differently from their npm counterpart | ||
| (`twilio-ruby`, `shopify_api`, `slack-ruby-client`, `sendgrid-ruby`) had no | ||
| such backstop and produced zero impacts: the provider left the report | ||
| entirely, so the repository read as unaffected. That is the same silent | ||
| underreport family as the wrapped-import fix above. Precision is unchanged: | ||
| `require_relative('gem')`, an identifier ending in the verb | ||
| (`myrequire('gem')`), a local path spelled like the gem (`require('./gem')`) | ||
| and a commented-out require all stay silent. | ||
| - **A Ruby `Kernel.require 'gem'` is no longer invisible.** `require` is a | ||
| private method on `Kernel`, so an explicit receiver — `Kernel.require 'gem'` | ||
| or `Kernel::require('gem')` — is equally valid Ruby, and it is what code | ||
| writes when a file defines its own `require` or when a generator emits fully | ||
| qualified calls. The matcher anchored on the bare verb at a statement | ||
| boundary, so the receiver form was rejected outright and the provider left the | ||
| report entirely: measured at 0 impacts against 36 for the identical bare | ||
| spelling. Same silent underreport family as the parenthesised-require fix | ||
| above, one call shape further out. Precision is unchanged: a lookalike | ||
| receiver (`MyKernel.require`), a chained receiver (`obj.kernel.require`), | ||
| `Kernel.require_relative` and a commented-out call all stay silent. | ||
| ## [0.5.7] - 2026-08-06 | ||
@@ -10,0 +414,0 @@ |
+1
-1
| { | ||
| "name": "mendapi", | ||
| "version": "0.5.7", | ||
| "version": "0.5.8", | ||
| "license": "AGPL-3.0-only", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+1
-1
@@ -15,3 +15,3 @@ # mendapi | ||
| That budget is measured, not aspirational: a 3,000-file repo is about where 30 seconds runs out. Large monorepos take proportionally longer. | ||
| That budget is measured, not aspirational: a 2,000-file repo is about where 30 seconds runs out. Large monorepos take proportionally longer. | ||
@@ -18,0 +18,0 @@ `sync` pulls the upstream API change feed into a local SQLite database. It is the only command that touches the network, and you run it once (then whenever you want fresher data). |
+143
-76
@@ -38,2 +38,3 @@ #!/usr/bin/env node | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { repairFamily } from './scanner.js'; | ||
@@ -56,21 +57,10 @@ const ROOT = dirname(fileURLToPath(import.meta.url)); | ||
| const argv = process.argv.slice(2); | ||
| const reportPath = argv[0]; | ||
| if (!reportPath || reportPath.startsWith('--')) usage(); | ||
| const pendingMode = argv.includes('--pending'); | ||
| const llmMode = argv.includes('--llm'); | ||
| const dryRun = argv.includes('--dry-run'); | ||
| const vIdx = argv.indexOf('--verdicts'); | ||
| const verdictsPath = vIdx >= 0 ? argv[vIdx + 1] : null; | ||
| const oIdx = argv.indexOf('--out'); | ||
| const outPath = oIdx >= 0 ? argv[oIdx + 1] : null; | ||
| const mIdx = argv.indexOf('--max'); | ||
| const maxItems = mIdx >= 0 ? parseInt(argv[mIdx + 1], 10) : Infinity; | ||
| if (!pendingMode && !verdictsPath && !llmMode) usage(); | ||
| // Module state shared by the helpers below and the CLI entry block. `review.js` | ||
| // is imported as a module (by the loop gates, and by anything reusing | ||
| // groupPendingByFamily), so the CLI must not run on import — same entry guard as | ||
| // scanner.js. | ||
| let report; | ||
| let maxItems = Infinity; | ||
| const report = JSON.parse(readFileSync(reportPath, 'utf8')); | ||
| if (!Array.isArray(report.impacts)) { | ||
| console.error('Not an impact report: missing impacts[]'); | ||
| process.exit(1); | ||
| } | ||
| const IS_CLI = !!(process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]); | ||
@@ -95,2 +85,7 @@ // ---------- pending list builder (shared by --pending and --llm) ---------- | ||
| source_type: im.change.source_type || null, | ||
| // The repair this finding belongs to, i.e. the changed property path with | ||
| // the endpoint it was observed on stripped off. Additive field: the entry | ||
| // shape is otherwise unchanged, so existing consumers of `--pending` are | ||
| // untouched. Findings sharing this key are one edit filed N times. | ||
| repair_family: repairFamily(im), | ||
| raw_excerpt: row?.raw_excerpt ? String(row.raw_excerpt).slice(0, 1500) : null, | ||
@@ -108,7 +103,30 @@ migration_hint: row?.migration_hint || null, | ||
| // ---------- pending mode ---------- | ||
| if (pendingMode) { | ||
| const pending = buildPending(); | ||
| console.log(JSON.stringify({ tool: 'mendapi-review/0.1', schema_version: 1, report: reportPath, pending_count: pending.length, pending }, null, 2)); | ||
| process.exit(0); | ||
| // Collapse a pending list to one representative per repair family, keeping the | ||
| // members so a single verdict can be fanned back out to all of them. | ||
| // | ||
| // Measured on stripe-samples/accept-a-payment (Loop 750): 214 medium findings, | ||
| // 41 distinct repairs — the four largest families are 25/23/23/23 records of a | ||
| // single property removal, filed once per endpoint that embeds the object. Asked | ||
| // one at a time, the LLM is handed the same question 25 times and charged for it | ||
| // 25 times, and can answer it inconsistently. The family key is the changed | ||
| // property path with the endpoint stripped, so members differ only in which | ||
| // endpoint they were observed on — the semantic question ("does this repo touch | ||
| // the changed surface") is identical across them. | ||
| // | ||
| // The representative is the FIRST member, which is the highest-ranked one: the | ||
| // caller's list preserves the scanner's severity/confidence/evidence ordering, so | ||
| // the member carrying real usage evidence leads its family and is the one the LLM | ||
| // sees. `endpoints_in_family` tells the model this is not a lone finding. | ||
| export function groupPendingByFamily(pending) { | ||
| const index = new Map(); | ||
| const groups = []; | ||
| for (const p of pending) { | ||
| const k = `${p.provider}\u0000${p.repair_family}`; | ||
| const g = index.get(k); | ||
| if (g) { g.members.push(p); continue; } | ||
| const ng = { key: k, rep: p, members: [p] }; | ||
| index.set(k, ng); | ||
| groups.push(ng); | ||
| } | ||
| return groups; | ||
| } | ||
@@ -128,3 +146,10 @@ | ||
| } | ||
| console.error(`Reviewing ${pending.length} impact(s) with ${config.provider} (${config.model})...`); | ||
| // One question per repair, not per change record: members of a family differ | ||
| // only in the endpoint they were seen on, so the LLM is answering the same | ||
| // question. Ask the leading member, then apply its verdict to the whole family. | ||
| const groups = groupPendingByFamily(pending); | ||
| const saved = pending.length - groups.length; | ||
| console.error(`Reviewing ${pending.length} impact(s) as ${groups.length} distinct repair(s)` | ||
| + `${saved ? ` (${saved} duplicate question${saved === 1 ? '' : 's'} skipped)` : ''}` | ||
| + ` with ${config.provider} (${config.model})...`); | ||
@@ -141,4 +166,6 @@ const SYSTEM = [ | ||
| const verdictsOut = []; | ||
| for (const p of pending) { | ||
| const prompt = JSON.stringify(p, null, 2); | ||
| for (const g of groups) { | ||
| const p = g.rep; | ||
| const prompt = JSON.stringify( | ||
| g.members.length > 1 ? { ...p, endpoints_in_family: g.members.length } : p, null, 2); | ||
| let raw; | ||
@@ -160,3 +187,8 @@ try { | ||
| } | ||
| verdictsOut.push({ change_id: p.change_id, verdict: parsed.verdict, rationale: String(parsed.rationale).slice(0, 500) }); | ||
| const rationale = String(parsed.rationale).slice(0, 500); | ||
| // Fan the one verdict out to every record of the same repair. Each still gets | ||
| // its own audit entry, so the applied report is per-record as before. | ||
| for (const m of g.members) { | ||
| verdictsOut.push({ change_id: m.change_id, verdict: parsed.verdict, rationale }); | ||
| } | ||
| } | ||
@@ -166,59 +198,94 @@ return verdictsOut; | ||
| // ---------- apply mode ---------- | ||
| const verdicts = llmMode | ||
| ? await llmVerdicts() | ||
| : JSON.parse(readFileSync(verdictsPath, 'utf8')); | ||
| const reviewerTag = llmMode ? `llm-byo-v1:${process.env.MENDAPI_LLM_PROVIDER || 'unknown'}` : 'llm-v1'; | ||
| if (!Array.isArray(verdicts) || verdicts.length === 0) { | ||
| if (llmMode) { | ||
| console.error('LLM produced no usable verdicts; report left untouched.'); | ||
| // ---------- CLI entry ---------- | ||
| // Guarded so importing this module (loop gates, reuse of | ||
| // groupPendingByFamily) does not run the command line. | ||
| async function main() { | ||
| const argv = process.argv.slice(2); | ||
| const reportPath = argv[0]; | ||
| if (!reportPath || reportPath.startsWith('--')) usage(); | ||
| const pendingMode = argv.includes('--pending'); | ||
| const llmMode = argv.includes('--llm'); | ||
| const dryRun = argv.includes('--dry-run'); | ||
| const vIdx = argv.indexOf('--verdicts'); | ||
| const verdictsPath = vIdx >= 0 ? argv[vIdx + 1] : null; | ||
| const oIdx = argv.indexOf('--out'); | ||
| const outPath = oIdx >= 0 ? argv[oIdx + 1] : null; | ||
| const mIdx = argv.indexOf('--max'); | ||
| maxItems = mIdx >= 0 ? parseInt(argv[mIdx + 1], 10) : Infinity; | ||
| if (!pendingMode && !verdictsPath && !llmMode) usage(); | ||
| report = JSON.parse(readFileSync(reportPath, 'utf8')); | ||
| if (!Array.isArray(report.impacts)) { | ||
| console.error('Not an impact report: missing impacts[]'); | ||
| process.exit(1); | ||
| } | ||
| console.error('Verdicts must be a non-empty JSON array.'); | ||
| process.exit(1); | ||
| } | ||
| const byChangeId = new Map(); | ||
| for (const im of report.impacts) byChangeId.set(im.change.id, im); | ||
| // ---------- pending mode ---------- | ||
| if (pendingMode) { | ||
| const pending = buildPending(); | ||
| console.log(JSON.stringify({ tool: 'mendapi-review/0.1', schema_version: 1, report: reportPath, pending_count: pending.length, pending }, null, 2)); | ||
| process.exit(0); | ||
| } | ||
| let applied = 0, skipped = 0, invalid = 0; | ||
| for (const v of verdicts) { | ||
| if (!v || !Number.isInteger(v.change_id) || !VERDICTS.has(v.verdict) || !v.rationale) { | ||
| console.error(`invalid verdict: ${JSON.stringify(v)}`); | ||
| invalid++; | ||
| continue; | ||
| // ---------- apply mode ---------- | ||
| const verdicts = llmMode | ||
| ? await llmVerdicts() | ||
| : JSON.parse(readFileSync(verdictsPath, 'utf8')); | ||
| const reviewerTag = llmMode ? `llm-byo-v1:${process.env.MENDAPI_LLM_PROVIDER || 'unknown'}` : 'llm-v1'; | ||
| if (!Array.isArray(verdicts) || verdicts.length === 0) { | ||
| if (llmMode) { | ||
| console.error('LLM produced no usable verdicts; report left untouched.'); | ||
| process.exit(1); | ||
| } | ||
| console.error('Verdicts must be a non-empty JSON array.'); | ||
| process.exit(1); | ||
| } | ||
| const im = byChangeId.get(v.change_id); | ||
| if (!im) { console.error(`skip change_id=${v.change_id}: not in report`); skipped++; continue; } | ||
| if (im.confidence !== 'medium') { console.error(`skip change_id=${v.change_id}: confidence=${im.confidence} not reviewable`); skipped++; continue; } | ||
| if (im.review) { console.log(`skip change_id=${v.change_id}: already reviewed`); skipped++; continue; } | ||
| const newConfidence = VERDICTS.get(v.verdict); | ||
| console.log(`${dryRun ? 'would set' : 'set'} change_id=${v.change_id} medium -> ${newConfidence} (${v.verdict}) ${im.change.title.slice(0, 60)}`); | ||
| const byChangeId = new Map(); | ||
| for (const im of report.impacts) byChangeId.set(im.change.id, im); | ||
| let applied = 0, skipped = 0, invalid = 0; | ||
| for (const v of verdicts) { | ||
| if (!v || !Number.isInteger(v.change_id) || !VERDICTS.has(v.verdict) || !v.rationale) { | ||
| console.error(`invalid verdict: ${JSON.stringify(v)}`); | ||
| invalid++; | ||
| continue; | ||
| } | ||
| const im = byChangeId.get(v.change_id); | ||
| if (!im) { console.error(`skip change_id=${v.change_id}: not in report`); skipped++; continue; } | ||
| if (im.confidence !== 'medium') { console.error(`skip change_id=${v.change_id}: confidence=${im.confidence} not reviewable`); skipped++; continue; } | ||
| if (im.review) { console.log(`skip change_id=${v.change_id}: already reviewed`); skipped++; continue; } | ||
| const newConfidence = VERDICTS.get(v.verdict); | ||
| console.log(`${dryRun ? 'would set' : 'set'} change_id=${v.change_id} medium -> ${newConfidence} (${v.verdict}) ${im.change.title.slice(0, 60)}`); | ||
| if (!dryRun) { | ||
| im.review = { reviewer: reviewerTag, verdict: v.verdict, rationale: v.rationale }; | ||
| im.confidence = newConfidence; | ||
| } | ||
| applied++; | ||
| } | ||
| if (!dryRun) { | ||
| im.review = { reviewer: reviewerTag, verdict: v.verdict, rationale: v.rationale }; | ||
| im.confidence = newConfidence; | ||
| // Re-sort: severity first, confidence second (same ordering contract as scanner). | ||
| const sevRank = { high: 0, medium: 1, low: 2, info: 3 }; | ||
| const confRank = { high: 0, medium: 1, low: 2 }; | ||
| report.impacts.sort((a, b) => | ||
| (sevRank[a.change.severity] ?? 9) - (sevRank[b.change.severity] ?? 9) || | ||
| (confRank[a.confidence] ?? 9) - (confRank[b.confidence] ?? 9)); | ||
| const dist = {}; | ||
| for (const im of report.impacts) dist[im.confidence] = (dist[im.confidence] || 0) + 1; | ||
| report.review_summary = { | ||
| reviewer: reviewerTag, | ||
| reviewed_at: new Date().toISOString(), | ||
| verdicts_applied: applied, | ||
| confidence_distribution: dist, | ||
| }; | ||
| const dest = outPath || reportPath; | ||
| writeFileSync(dest, JSON.stringify(report, null, 2) + '\n'); | ||
| console.log(`\nwrote ${dest}`); | ||
| } | ||
| applied++; | ||
| console.log(`applied=${applied} skipped=${skipped} invalid=${invalid} dryRun=${dryRun}`); | ||
| if (invalid > 0) process.exit(1); | ||
| } | ||
| if (!dryRun) { | ||
| // Re-sort: severity first, confidence second (same ordering contract as scanner). | ||
| const sevRank = { high: 0, medium: 1, low: 2, info: 3 }; | ||
| const confRank = { high: 0, medium: 1, low: 2 }; | ||
| report.impacts.sort((a, b) => | ||
| (sevRank[a.change.severity] ?? 9) - (sevRank[b.change.severity] ?? 9) || | ||
| (confRank[a.confidence] ?? 9) - (confRank[b.confidence] ?? 9)); | ||
| const dist = {}; | ||
| for (const im of report.impacts) dist[im.confidence] = (dist[im.confidence] || 0) + 1; | ||
| report.review_summary = { | ||
| reviewer: reviewerTag, | ||
| reviewed_at: new Date().toISOString(), | ||
| verdicts_applied: applied, | ||
| confidence_distribution: dist, | ||
| }; | ||
| const dest = outPath || reportPath; | ||
| writeFileSync(dest, JSON.stringify(report, null, 2) + '\n'); | ||
| console.log(`\nwrote ${dest}`); | ||
| } | ||
| console.log(`applied=${applied} skipped=${skipped} invalid=${invalid} dryRun=${dryRun}`); | ||
| if (invalid > 0) process.exit(1); | ||
| if (IS_CLI) await main(); |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1119825
15.13%17523
12.23%