New:Socket for Asana Is Now Available.Learn more
Get Started

mendapi

Package Overview
Dependencies
Maintainers
1
Versions
9
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

mendapi - npm Package Compare versions

Comparing version
0.5.7
to
0.5.8
+404
-0
CHANGELOG.md

@@ -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",

@@ -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