# Composer scan (/v1/composer/scan)

One POST with a site's composer files reports, for every `drupal/*` package,
whether a release supports a target core and which packages have none. With
`patches: true` the same call judges each composer patch against the release
its package would install.

## Request

### POST /v1/composer/scan

```json
{"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`. |
| `patches_file` | Optional. The patches file of a site on cweagans/composer-patches 2.x. Its path is in the site's own `extra.composer-patches.patches-file`, `patches.json` by default. Such a site declares nothing under `extra.patches`, so a scan without this reports it as unpatched. Read after `extra.patches`; a source a package already declares is kept once, as the manager keeps it. |
| `patches_lock` | Optional. `patches.lock.json`, which 2.x writes and applies from. It says what the site actually applies, so it answers alone: `extra.patches` and `patches_file` are left unread. |
| `target_core` | `11.4`, `11.4.x` (read as `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. A target whose minor has no core release in the data is a 400, and so is a major alone such as `11`. `latest` and the installed core are never refused this way. |
| `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.

### 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. |
| `patches_file` | The file. As for the JSON body. |
| `patches_lock` | The file. As for the JSON body. |
| `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. |

Every document accepts both declaration shapes. One is the compact
title-to-source object. The other is a list of `{description, url, sha256,
depth, extra}` objects. A definition's `extra.drupatch` is read as the
patch's `provenance`.

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
- `next_step`

Without `patches`, the answer is the release scan as for the JSON body. The
caps of the JSON body apply.

### 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. The two share a version and a datestamp,
and the sub-module requires the project.

```json
{"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.

### GET /v1/composer/scan

GET answers this page. The scan itself is a POST, because the lock and the
patch text do not fit in a query string.

## 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 counts in the
patch check rate limit 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, upstream, provenance}]`: the declarations the caller resolved from its own patch manager. When sent, they replace what `extra.patches` says. `provenance` says where a copied patch's bytes came from: `{mr, commit, url, base, head, fetched, rerolled}`. Send it when `source` is a local path. |
| `reroll` | `true` adds a re-rolled diff under `result.reroll` to every `conflicts` row, and to every `fuzzy` row. A fuzzy row applies today, and a patch manager running `git apply` alone refuses it. The diff has `status` (clean or conflicts), the text in `patch`, and `verified` when the service apply-checked it. Each re-roll runs a scratch merge. |
| `drop_tests` | What each re-roll does with a file the release package leaves out, as on `/v1/patch/check`. Unset and `true` both leave it out; `false` keeps every hunk. `result.reroll.dropped_paths` lists what went. |

A package's patches are judged in the order they are declared, and the order
of `patch_config` is the order they are judged in. Packages have no order
between them. Sending `patch_files`, `candidates` or `patch_config` without
`patches: true` is a 400.

## 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.

## 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. |

Every row also has these, when they apply.

| Field | What it holds |
|---|---|
| `installed_supports` | True when the installed release's own constraint covers the target core. |
| `installed_unknown` | True when the release data does not have the installed version at all. Such a row offers no older release. |
| `installed_from_tag` | True when the release data did not have the installed version and its own git tag declared the answer. |
| `submodule_of` | The package that provides this one. A sub-module has no releases of its own and takes its project's answer. |
| `constraint` | The site's own requirement for the package, empty for a transitive dependency. |
| `blocked_by_constraint` | A release that supports the target and the constraint forbids. Set only with `no_release`. |
| `blocked_by_stability` | A release that supports the target and is below the site's minimum stability. Set only with `no_release`. |
| `decided_by` | Where the candidate came from: `composer` when the caller resolved it, `bundle` when this scan chose it. |
| `prerelease` | True when `latest` is an alpha, beta or rc. |
| `known_latest` | The newest release the data has when the row cannot judge the installed one. It may be older than what the site runs. |

### 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` | Each warning opens with a package name, and states a blocker the counts were computed around. A blocked package whose blocker the site owns gets 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, so an `applies` patch
can still be wrong at runtime. The same files and target are answered from
cache, which `X-Plan-Cache` reports as `hit` or `miss`.

## Limits

| What | Cap |
|---|---|
| Request body | 32 MB |
| `composer_json` | 1 MB |
| `composer_lock` | 4 MB |
| Packages in one lock | 400 |
| Patches one plan judges | 200 |

The api-wide per-IP rate limit applies to every scan. A scan with
`patches: true` also counts in the patch check rate limit: 30 requests per
minute per IP, shared with `/v1/patch/check`. 100 requests over the limit
within 15 minutes ban the address from both for 15 minutes. One request
starts a semver process per package and a git sweep per patch. A release
scan is not cached. A plan is, for the same files and target. This page is
cached for 24 hours.

## Errors

| Status | When |
|---|---|
| 400 | The body or a file does not parse, or a cap is passed. Also a candidate that is not a version the lock installs, and a patch half field sent without `patches: true`. Also a `target_core` whose minor has no core release in the data, or a major alone. |
| 413 | The body is over 32 MB. Also one patch over 32 MB once encoded for the patch check. |
| 429 | The patch check rate limit refused the scan, or the address is banned. `Retry-After` gives the wait in seconds. |
| 503 | The patch check is busy. |
| 502 | The patch check or the constraint resolver could not answer. |
