Composer scan

Which of a site's drupal/* packages need a new release for a target core, and which have none? One POST with the site's composer files answers for every package. With patches: true the same call judges each composer patch against the release its package would install.

What is kept

What you send is cached at the service to improve it: the composer fields this page describes, and the text of each patch. Nothing beyond what was sent is kept, and what is sent is the filtered minimum described here.

POST /v1/composer/scan

{"composer_json": "<composer.json text>",
 "composer_lock": "<composer.lock text>",
 "target_core": "11.4.5",
 "patches": true,
 "patch_files": {"patches/webform.patch": "<diff text>"}}
Field What it holds
composer_lock Required. The whole file, or a slim form with only drupal/* entries under packages and packages-dev. Each entry has name and version, plus source.reference when the lock has it; a sub-module also has type, require and extra.drupal.datestamp. Without those three, a sub-module comes back as an unknown project.
composer_json Optional. Adds the constraints and extra.patches.
target_core 11.4, 11.4.5, or latest for the newest core the site's own constraint allows. Empty scans against the installed core (target_is_installed): what can be updated without a core upgrade.
installed_core Optional. Composer name to the drupal/core requirement its installed release declares, read from the site's vendor directory. That release is then judged by what it declares.

Constraints are read by composer's own semver library. Body at most 32 MB, at most 400 packages.

The slim lock

A lock with only the fields the scan reads, a few KB instead of hundreds. source.reference is the commit the installed release was cut from; with patches: true and a target core, a re-roll tries it first as its merge base. type, require and extra.drupal.datestamp pair a sub-module with the project that provides it: drupal.org packages a sub-module as a metapackage built from its project's release, so the two share a version and a datestamp, and the sub-module requires the project.

{"packages": [
  {"name": "drupal/core", "version": "10.6.9"},
  {"name": "drupal/webform", "version": "6.2.9",
   "source": {"reference": "3f2c1a9e7b0d4c6a8e1f2b3c4d5e6f7a8b9c0d1e"}},
  {"name": "drupal/domain", "version": "3.0.1", "type": "drupal-module",
   "extra": {"drupal": {"datestamp": "1778231514"}}},
  {"name": "drupal/domain_access", "version": "3.0.1", "type": "metapackage",
   "require": {"drupal/core": "^10.2 || ^11", "drupal/domain": "*"},
   "extra": {"drupal": {"datestamp": "1778231514"}}}
],
 "packages-dev": [{"name": "drupal/devel", "version": "5.3.2"}]}

Only a metapackage needs require, and only its drupal/ entries.

Upload

The same call as multipart/form-data, for a shell. The files go from disk to the api and never pass through a model's context. curl alone is enough.

curl -sS https://api.tresbien.tech/v1/composer/scan \
  -F composer_json=@composer.json -F composer_lock=@composer.lock \
  -F target_core=11.4.5 -F patches=1 \
  -F 'patches/a.patch=@patches/a.patch' \
  -F 'https://www.drupal.org/files/issues/3398797-21.patch=@3398797-21.patch'
Part What it holds
composer_lock Required. The file.
composer_json The file. Optional; without it there are no constraints and no patches.
target_core As for the JSON body.
patches 1 or true turns on the patch half.
any other name One patch file. The part's name is the source as the declaration writes it: the local path, or the URL the file was fetched from. A merge request .patch also sends its .diff sibling under its own URL.

The answer is the compact plan: counts, the packages that need work, one row per patch that needs a decision, missing_files for the patches not sent, and next_step. Without patches, the answer is the release scan as for the JSON body. The caps and limits of the JSON body apply.

The patch half

patches: true judges every declared patch and adds a plan object to the response. It runs git apply against the release tag, so it has its own rate budget and must be asked for.

Field What it holds
patch_files The text of every patch listed in the declarations, keyed by the source as written: the path or its base name, or the URL. The service downloads nothing.
candidates Composer name to version: the release the caller's own composer picked for each package. When sent, it decides which release a patch is judged against. Each row says which decided it in decided_by. A candidate the lock does not install, or a value that is not a version, is a 400.
patch_config [{package, title, source}]: the declarations the caller resolved from its own patch manager. When sent, they replace what extra.patches says.
reroll true adds a re-rolled diff to every conflicts row under result.reroll. It has status (clean or conflicts), the diff in patch, and verified when the service apply-checked it. Each one costs a scratch merge.

A package's patches are judged in the order they are declared. Send patch_config in that order. Packages have no order between them. At most 200 patches. Sending patch_files, candidates or patch_config without patches: true is a 400.

Response

{target_core, core_installed, bundle_date, counts, rows, patches, unlocked, outside_drupal}, plus plan when the patch half ran.

Field What it holds
rows One row per package, ranked: no_release, update, unknown, current.
rows[] no_release No published release supports the target. latest_any and dev_branch say what exists.
rows[] update A compatible release the site does not have: latest, latest_date, latest_core.
rows[] unknown Not a drupal.org project in the bundle.
rows[] current The installed release supports the target and nothing newer is needed.
counts The package status tally.
patches extra.patches as /v1/patch/check items with the installed version filled in. Left out when plan.patches has the same list judged.
unlocked Packages the lock does not install.
outside_drupal Patches declared on packages outside drupal/. They get no row.
bundle_date When the release data was published. A release after it is invisible here.

plan

{counts, no_release, patches, missing_files, warnings}.

Field What it holds
counts The patch verdict tally: merged (the fix is in the release), applies (applies cleanly), conflicts (no longer applies), unknown (no verdict; the row says why). Separate from the top-level counts.
no_release The packages with no installable release for the target.
patches One row per declared patch, in composer.json order, each with the verdict detail.
patches[].version The release the verdict is about.
patches[].installed The release the lock holds, when it differs.
patches[].note For an unknown row: what blocks it.
patches[].result.core_references What the added code references in core, at target_core. The same block /v1/patch/check returns.
missing_files Local patch paths whose text was not sent.
warnings Read before trusting the counts. Each warning opens with the package name. A blocked package whose blocker the site owns gets one that gives the requirement to change: its own constraint, or its minimum stability.

A patch on a package with no release for the target is judged against the branch when the lock installs a dev version. Otherwise it is unknown. Verdicts come from git apply against the release tag; an applies patch can still be wrong at runtime. The same files and target are answered from cache, which X-Plan-Cache reports.

Errors

400 for the body, the files or the caps. 503 when the patch check is busy; retry in a few seconds. 502 when the patch check or the constraint resolver could not answer.