SCORECARD RUBRIC
The scorecard is a weighted rubric applied to every published extension. Each factor contributes a fixed number of points. The total is divided by the sum of all weights and expressed as a percentage and letter grade.
Factors
The current rubric, version 6, defines twelve active factors. The server-side
divisor is 16. The registry and swamp extension quality score with the same
package, @swamp-club/extension-scoring, so an archive gets the same rows from
both.
| # | Factor ID | Label | Points | Earned when |
|---|---|---|---|---|
| 1 | has-readme |
Has README or module doc | 2 | The tarball contains a README file (README.md, README.MD, readme.md, or Readme.md) at the extension root or under files/. |
| 2 | readme-example |
README has a code example | 1 | The README contains at least one fenced code block. |
| 3 | rich-readme |
README is substantive | 1 | The README is at least 500 characters and contains at least two fenced code blocks. A version with no README file shows the factor missing. |
| 4 | symbols-docs |
Most symbols documented | 1 | At least 80% of exported symbols across all entry points carry a JSDoc comment. |
| 5 | fast-check |
No slow types (deprecated) | 1 | Deprecated and not evaluated: earned by every analysed version. |
| 6 | description |
Has description | 1 | The extension's description field contains a character other than whitespace. |
| 7 | platforms |
Platform support declared (or universal) | 2 | The platforms array has one or more entries, or is empty. An empty array matches no platform filter in registry search. Single-platform manifests earn full credit — platform breadth is not a quality proxy. |
| 8 | has-license |
License declared | 1 | The manifest's license: is a valid SPDX expression. Only SPDX ids count; an invalid expression earns nothing. |
| 9 | license-file |
LICENSE file included | 1 | A non-empty license file (LICENSE, LICENSE.md, LICENSE.txt, COPYING, and common variants) is present at the tarball root or under files/. |
| 10 | repository-verified |
Verified public repository | 2 | The repository URL is set and points to a publicly accessible repository on an allowlisted host, confirmed via the host's public API — or the extension is private and its collective has private source hosting. |
| 11 | dependency-trust |
Dependencies pass trust audit | 2 | Every npm: and jsr: dependency is audited at the version that ships. Both points with no blocker, unaudited dependency or loose specifier; one point when loose specifiers are the only cost. |
| 12 | secret-safe-docs |
Docs keep secrets in a vault | 1 | No secret-unsafe-docs finding is left unaccepted in the packaged Markdown, the manifest description, or source describe strings. |
The search API returns only extensions whose scored version ships a license file
when called with licensed=true; an extension not yet scored, or whose scoring
failed, is still returned.
The extension page shows has-license and license-file as a single
License line scored out of 2, naming the part that is missing. The scorecard
API reports them separately.
The maximum server-side score is 16/16 = 100% (Grade A). Every factor above is
earnable by any publisher.
A scorecard is composed under the rubric version it was computed with until it
is republished or rescored, which scores it under the current rubric. A version
scored under rubric 3 has no secret-safe-docs row and a divisor of 15. The
description and rich-readme rules above apply to every stored scorecard as
it is read, whichever rubric scored it.
Factor status
Each row of the breakdown has one status:
| Status | Meaning |
|---|---|
earned |
All of the factor's points. |
partial |
Some of its points. Only dependency-trust can be partial: 1/2 when loose specifiers cost. |
missing |
None of its points. |
pending |
Not scored yet: the version has not been analysed. It shows every current factor, out of 16. |
provisional |
Shown only by the CLI before publish; see below. It earns 0 until the registry verifies it. |
The percentage is the earned points times 100 divided by the maximum, rounded
down. The registry and swamp extension quality both divide by the full
maximum.
Provisional factors in the CLI
Only the registry can confirm that a repository URL is a public repository (it
calls the host's public API). When the URL is HTTPS on an allowlisted host, the
CLI marks repository-verified as provisional: it earns 0 points locally
and its 2 points stay in the divisor. An extension that earns every other factor
scores 14/16 = 87% (Grade B) in the CLI, and the rubric line names what the
pending points would raise it to:
Rubric v6 — 14/16 points (87%, grade B, +2 pending server verification: 100% (A) if the registry verifies them, all factors earned)The registry awards the 2 points once verification succeeds. --json reports
the pending points as provisionalPoints and the raised score as
ifProvisionalEarned.
A private extension whose collective has
private source hosting needs no verification: the CLI
scores the factor earned, labelled "Private repository (collective attested)",
whatever the repository URL. Otherwise the factor is missing. See
Publishing § quality.
File placement for has-readme and license-file
Both file lookups are exact at the extension root and under files/ — the
analyzer does not recurse. Entries in additionalFiles are preserved
verbatim as the archive sub-path under extension/files/, so a nested entry
like additionalFiles: [docs/README.md] lands at
extension/files/docs/README.md and earns zero. Use a bare basename in
additionalFiles. If your repository keeps each extension self-contained in its
own subdirectory (manifest beside README beside LICENSE), opt into
paths.base: manifest so the bare-basename entry resolves against the
manifest's own directory.
When deno doc cannot analyse the source
The analyzer runs deno doc on the manifest's entry points with no import map.
Push writes each bare import the project's Deno config resolves as its target
specifier, and each bare import package.json mode resolves to an installed
package as npm:<name>@<installed version>, so those resolve. When an import
does not resolve — a bare import that shipped as written, a local module missing
from the archive, or a remote host the scorer may not fetch from — the version
is still scored:
symbols-docsearns 0.has-readmeis earned only by a README file, since module docs cannot be read.fast-checkis not evaluated and keeps its deprecated full credit.- Every other factor, including
has-licenseandlicense-file, is scored as usual.
The reason appears under the factor breakdown on the extension page, and as
docAnalysisError in the scorecard API response (null when the source
resolved). It names at most three unresolved imports, with paths relative to the
extension root.
Verified by Swamp badge
The Verified by Swamp badge is displayed as a shield pill next to the score ring on the extension detail page. It is awarded when either of the following is true:
- The extension is published under the first-party
@swampnamespace. - The latest version meta has
verifiedBySwamp: true, set by a System Initiative admin. The admin curation endpoint is planned and not yet available.
The badge does not contribute points to the score. An extension can score 100% without the badge, and an extension can carry the badge at any score. See About the Extension Scorecard for why verification is a badge rather than a scored factor.
Grade thresholds
| Grade | Minimum score |
|---|---|
| A | 90% |
| B | 75% |
| C | 60% |
| D | 40% |
| F | < 40% |
Repository verification
The repository-verified factor is earned by verification for repositories
hosted on one of the following allowlisted hosts:
| Host | API endpoint |
|---|---|
github.com |
GET https://api.github.com/repos/:owner/:repo |
gitlab.com |
GET https://gitlab.com/api/v4/projects/:url-encoded-path |
codeberg.org |
GET https://codeberg.org/api/v1/repos/:owner/:repo |
bitbucket.org |
GET https://api.bitbucket.org/2.0/repositories/:workspace/:repo |
Verification calls the host's public API with the owner and repository path —
the owner-supplied URL is not fetched directly. A 200 response from the host
API with the repository marked public counts as verified. Any other outcome
(404, private repository, rate limit, network error, malformed URL) counts as
not verified. The result is cached either way; see the caching rules below for
when a negative result is re-checked.
GitLab verification supports nested group paths by URL-encoding the full project
path. The gitlab.com public SaaS instance is the only GitLab host recognised —
self-hosted GitLab instances are covered by
private source hosting instead.
Verification results are cached on the extension record: a verified result for 7
days, an unverified result for one hour. The cache is cleared and re-checked
when the owner changes the repository URL. It is also re-checked when a
scoring event fires (a new version is published or the owner triggers a manual
rescore) and the stored result is older than its window. A successful
verification of a URL is applied to every extension whose current repository
field equals that URL, across all owners — one publish or rescore verifies every
extension sharing the repo. A failed verification is recorded only on the
extension that triggered the check; failures can be transient and never
overwrite a sibling's verified state. The score worker does not re-verify on its
own schedule — if no scoring event fires for any extension on the URL, a stale
cache stays as-is until the next publish or rescore.
Private source hosting
A collective whose extension source lives on a private or self-hosted forge
(GitHub Enterprise, self-hosted GitLab, Gitea, Forgejo, or a private repository
on a public host) can be marked by Swamp Club for private source hosting. Every
private extension that collective owns then earns repository-verified. The
factor reads Private repository (collective attested) on the scorecard and
in GET /api/v1/extensions/:name/score, and is worth the same 2 points.
Public extensions earn the factor only through a verified public repository, whether or not their collective has private source hosting. Swamp Club staff set private source hosting; ask through your account contact or the Swamp Club Discord to enable it for a collective.
Dependency trust audit
The dependency-trust factor reads every npm: and jsr: specifier the
extension's code imports and audits each dependency at one version: the version
that ships. See Version resolution.
Code means every .ts, .tsx, .mts, .cts, .js, .jsx, .mjs and .cjs
file in the archive except tests: a file whose name ends _test or .test
before its extension is not read. Only import and export statements and dynamic
import() calls count. An import inside a comment, and a string that merely
looks like a specifier, is not a dependency. A module that does not parse is
not audited: its imports are unknown.
| Source | Data queried |
|---|---|
| OSV.dev | Known npm vulnerabilities and their severity (LOW, MEDIUM, HIGH, CRITICAL, UNKNOWN) |
| npm registry | Published versions, and the resolved version's deprecation, license, maintainers |
| npm downloads API | Weekly download count |
JSR (jsr.io) |
The package's published versions, and whether each is yanked |
Two specifiers of one package that name different versions are audited
separately. npm:zod is neither listed nor audited: the host provides it.
Version resolution
How the version is chosen depends on the archive's dependency-audit.json,
which push writes at the archive root; see
Publishing Commands § Dependency audit record.
The registry reads it only as a regular file of at most 1 MiB.
Archives with a recordVersion: 2 record
Push writes every npm: and jsr: import in the archive's source at one exact
version, which the record lists (the canonical form). The registry audits only
an exact specifier the record lists, at that version, with resolvedFrom set to
the entry's from. It reads no lockfile and resolves no range against the
registry. A range, a dist-tag, an unpinned import, an exact version the record
does not list, or one whose version the record does not match is
not audited.
OSV, deprecation and yank checks still run on the listed version. Each entry in
the record's unaudited list (a URL import, a git dependency, or a local
package.json dependency) is not audited and named by its origin.
Archives with a recordVersion: 1 record, or none
| Specifier | Version audited | resolvedFrom |
|---|---|---|
Exact pin (1.2.3, =1.2.3, v1.2.3) |
That version | exact |
| Any other specifier with a usable record entry | The version the record lists for it | The entry's from, or registry when it names no known source |
Range, or no version, with a matching deno.lock entry |
The version the lock records for that specifier; otherwise the highest locked version the specifier allows | lockfile |
Range (^4.18.0, ~1.2, 4) |
npm: the highest published version it allows. JSR: the highest one that is not yanked, else a yanked match | registry |
Dist-tag (next) |
npm: the version the dist-tag points to. JSR has no dist-tags: version <tag> not found on JSR |
registry |
| No version | npm: latest. JSR: the package's latest |
registry |
Prereleases never satisfy a range. The rows apply in order: the first that matches decides.
A v1 record is a JSON object with recordVersion: 1 and a dependencies array;
anything that is neither v1 nor v2 counts as no record. The registry uses a v1
entry for an import only when all of these hold:
registryisnpmorjsr, andnameis not empty.versionis a plain semver version, with novor=.- The entry's
specis the import's version exactly as the source writes it (^2and^2.0.0are different), and it admits the version: an exact spec names it, a range contains it, and a dist-tag or no spec admits any version. - It is the first usable entry for that registry, name and
spec. - The registry publishes that version.
An entry that fails any of these is ignored, and the dependency resolves by the rows below it. A recorded JSR version is audited as recorded even when it is yanked. An archive with no record resolves every dependency by the other rows.
Only a deno.lock at the archive root is read, in lockfile format 4 or 5; a
lockfile in a subdirectory belongs to another project and is ignored, as is a
lock path in deno.json.
Findings
Blockers (any one costs both points):
- The resolved version is deprecated on the npm registry, and so is
latest:package is deprecated. - The resolved version is deprecated but
latestis not:version 1.0.0 is deprecated: <npm notice>. A pin to a good version is not flagged when onlylatestis deprecated. - The resolved version has a HIGH, CRITICAL, or UNKNOWN severity vulnerability in OSV.dev.
- The JSR package does not exist (
JSR package not found), its name is not a valid JSR name (invalid JSR package name), or no published version matches the specifier (version 0.0.999 not found on JSR). - The resolved JSR version is yanked:
version 0.225.0 was withdrawn (yanked) by its author on JSR. Newest version that isn't yanked: 1.0.0.The newest version is the newest stable release on the yanked version's major line (its minor line for0.x), else the newest overall. With no other version, the message endsNo other version is available.
Loose specifiers (cost one of the two points, however many there are):
A specifier that is unpinned, a range or a dist-tag, and that no valid lock fixed. A version a
deno.lockdecided, or apackage-lock.jsonthatnode_modulesmatches, costs nothing: pinning exact versions and committing a lock score the same.- In a v1 archive, or one with no record:
npm:express@^4.18.0 is a range and no lock fixed it; audited 4.21.1, the version the registry resolved, orthe version push recordedwhen the record decided. - In a v2 archive, the record entry's
fromisregistry, orinstalledwithlocked: false, and the finding names the spelling the author wrote:npm:ms@^2 is not an exact version and no lock fixed it; audited 2.1.3, the version push recorded.
Listed with the warnings.
- In a v1 archive, or one with no record:
Warnings (recorded on the scorecard; cost no points):
- MEDIUM severity vulnerability. A LOW one is not reported.
- License of the resolved version not in the allowlist:
license "GPL-3.0" not in allowlist. The license is read as an SPDX expression, and passes when every license in it is one of MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD, MPL-2.0, Unlicense and CC0-1.0, soMIT OR Apache-2.0passes. A license with an exception (WITH), an-or-lateror+license, an expression that does not parse, and no license at all are warned about. - No maintainers listed for the resolved version on the npm registry.
- Fewer than 1,000 weekly downloads.
- The package's latest version was published more than 24 months ago, a month being 30.44 days.
License and maintainers come from the resolved version's npm entry, falling back to the package-level field when that entry has none.
Not audited (any one costs both points, and is listed by name with the
reason on the scorecard and as dependencyTrust.notAudited in
GET /api/v1/extensions/:name/score):
- The npm registry lookup failed, including a package npm does not know.
- The package name is not a valid npm name:
invalid npm package name. It is not looked up. - The resolved version is not one npm lists, or npm lists it without a manifest:
npm lists no version 9.9.9. - The OSV.dev lookup failed, OSV.dev answered with a
vulnsfield that is not a list, or OSV.dev could not be asked because no published version matches the specifier. - The JSR lookup failed:
JSR lookup failed (HTTP 503). - The dependency is past the 50-dependency query limit. The 50 audited are the first in registry, name and version order.
- In a v2 archive, a specifier breaks the canonical form:
npm:ms@^2.1.0 is not in the canonical form: it must name one exact version, such as npm:ms@1.2.3, ornpm:ms@2.1.3 is not in the canonical form: dependency-audit.json lists no npm:ms@2.1.3. - In a v2 archive, code from outside npm and JSR, listed under its origin:
a URL import, from outside npm and JSR: no registry audit covers it. Replace it with an npm: or jsr: package.A git dependency (git…,github:,gitlab:,bitbucket:oruser/repo) readsa git dependency, and afile:,link:,workspace:,portal:or path dependencya local package.json dependency. - A code module that does not parse, listed under its archive path:
Its imports cannot be read: the module does not parse, so its dependencies were not audited.
An exact pin is checked against OSV.dev even when the npm registry lookup fails, so a known vulnerability in it is listed with the blockers as well as the dependency being not audited.
A failed weekly download lookup does not leave a dependency unaudited; the download warning is skipped instead.
Score API fields
dependencyTrust in GET /api/v1/extensions/:name/score carries:
| Field | Content |
|---|---|
dependencies |
Each specifier found. npm entries are unprefixed (ms@2.1.3); JSR entries are prefixed (jsr:@std/assert@1). npm:zod is neither listed nor audited. |
audited |
One entry per dependency: registry, packageName, specifier, version (null when none resolved) and resolvedFrom (exact, installed, lockfile, registry, or null; a version the record decided reports the entry's from). |
blockers |
packageName, reason, and cause where one applies: deprecated-version, jsr-yanked, jsr-missing. |
warnings |
packageName, reason, and cause where one applies: loose-specifier. |
notAudited |
packageName and reason. packageName is the package, or the archive path of a module that does not parse, or the origin of code from outside npm and JSR. |
passed |
Whether there is no blocker and no dependency not audited. true when a loose specifier costs one point. |
earnedPoints |
The factor's points: 2, 1 or 0. null while the factor is pending. |
At push
swamp extension push and swamp extension quality run the same audit with the
same rules, at the version the push ships, and push records that version in the
archive so the registry audits the same one. The loose specifier warning reads
no lock: ^2.1.0 resolved to 2.1.3 today; commit a lock or pin exact versions (costs 1 point).
Code from outside npm and JSR is not audited there too:
not audited: https://example.com/x.ts is a URL import, from outside npm and JSR, so no registry audit covers it.
In package.json mode a bare import is audited at the version installed in
node_modules, each installed copy separately. A package-lock.json counts as
a lock only when node_modules matches it, transitive packages included, and
only when the project has no deno.json; otherwise push warns with
package-lock-mismatch.
The CLI reads the lockfile its bundler honours, not one at the archive root:
| Project | Lockfile read |
|---|---|
deno.json with lock set to a path |
That path, relative to the deno.json |
deno.json with lock: { "path": … } |
That path, relative to the deno.json |
deno.json with lock: false |
None |
deno.json with no lock |
The deno.lock beside it |
| A config that cannot be parsed | None |
| A workspace member | The workspace root's lock |
package.json mode |
The deno.lock in the package.json dir |
| Neither | None; the bundler runs with --no-lock |
deno.json here also means deno.jsonc; both are read as JSONC. In
package.json mode the lock applies only to npm: and jsr: imports written
in the source; a bare import resolved to an installed package is audited at the
installed version. With no lockfile, Deno resolves a loose specifier from its
cache or the registry, and push ships and records that version.
The CLI scores the factor on the same findings as the registry: both points are
lost to any error, a deprecated-version or jsr-yanked warning, or a
dependency not audited; otherwise one point is lost when any dependency has a
loose-specifier warning, however many do. A deprecated pin therefore shows the
factor as 0/2 with 1 dependency blocker(s) found, and a loose specifier
alone as 1/2, while dependencyTrust.passed in --json stays true:
passed says only that nothing blocks the push.
A push prompts before publishing an unaudited dependency or any warning with a
cause (deprecated-version, loose-specifier, jsr-yanked); the other
dependency warnings are advisory. See
Waived warnings.
The audit runs at publish time and again on a rescore, which re-queries every
dependency and replaces the findings. A rescore reads the same archive, so a
version the record decided stays the version audited while the registry still
publishes it. Up to 50 dependencies are queried per extension. An extension with
no npm: or jsr: dependencies earns the factor, unless a module does not
parse or the record lists code from outside npm and JSR.
Secret-safe docs
The scorer reads the documentation a user copies from: every .md file in the
archive, the manifest description, and the strings passed to .describe(...)
or set as description: in every .ts file that is not a test, include files
and modules outside the kind directories among them. Three kinds of Markdown are
not documentation and are not read: a binaries: entry, an include: entry,
and a .md file a module imports by a relative path, which is data. Lines
longer than 2,000 characters are skipped. A scan that does not read everything
is incomplete and does not earn the factor: a file over 1 MiB is not read, an
archive with more than 10,000 such files is not read past them, a file or
directory the scorer cannot open is skipped, and the scan stops at 1,000
unaccepted findings.
It reports a secret-unsafe-docs finding for each line that:
- uses vault reference syntax Swamp does not resolve:
vault://,<vault:,{{ vault ... }}without the leading$, orswamp vault set - sets a secret
--global-argor--inputto anything other than a${{ ... }}expression. An argument is a secret when its schema marks it.meta({ sensitive: true }), or when no schema declares it and its name reads as a token, key, secret or password. The schemas read are the models' global arguments and method arguments, the methods an extension module adds to another model type, and vault and datastore config fields, as push sends them and as the registry reads them from the published archive. A field either marks sensitive is a secret, so a version stored before the registry kept the mark, or pushed by a CLI that does not send it, is judged by its schema on a rescore.trueandfalsevalues pass. - passes the value to
swamp vault puton the command line, unless the command uses--serveror the value is a shell variable ($TOKEN,"${TOKEN}") - contains a full-length credential: a GitHub, GitLab, Anthropic, AWS, Slack,
HashiCorp Vault or Swamp API key, a bearer token, or a
user:pass@URL. A run of one repeated character, and a URL onlocalhost, a loopback address or anexample.com,example.orgorexample.nethost, pass.
In Markdown, credentials count anywhere; the other patterns count only inside fenced code blocks. A command continued with a trailing backslash is checked as one line and reported on its first line.
A finding is accepted when the push that published the version declared it
accepted: the scorecard reads the acceptances from the version's content
metadata. A Markdown or source finding is accepted by an entry for the same file
and line; a manifest description finding by any secret-unsafe-docs
acceptance on the manifest. The factor is earned when no finding is left.
secretDocs in GET /api/v1/extensions/:name/score carries:
| Field | Content |
|---|---|
passed |
Whether no finding is left unaccepted. |
findings |
Up to 50 unaccepted findings: file (the archive path, absent for the manifest description), line, and message. A description finding's message starts The description. |
count |
The number of unaccepted findings, including any past the first 50. |
truncated |
true when the scan was incomplete; the factor is then not earned and count is a lower bound. |
secretDocs is absent on a scorecard computed under rubric 3.
swamp extension quality and swamp extension push report the same findings
with the same wording; see
Publishing § quality.
Scoring locally as the registry does
swamp extension quality scores with the same rubric. Its --isolated flag
runs deno doc with an empty dependency cache and no registry credentials, as
the registry does, so an import that resolves only locally fails there too. See
Publishing § quality.
Factor data sources
Factors are evaluated against two kinds of data:
| Data scope | Stored on | Updated by |
|---|---|---|
| Per-version | Version meta record | Tarball analyzer at publish time; re-run on the owner's "Rescore" action. |
| Per-package | Extension record | Read live from the extension's metadata. Edits to description, platforms, repository and homepage update the score instantly. |
| Verification | Extension record | Repository verifier. Verified results kept 7 days, unverified one hour; re-checked on URL change, publish, or rescore. |
Per-version factors: has-readme, readme-example, rich-readme,
symbols-docs, fast-check, has-license, license-file, dependency-trust,
secret-safe-docs.
Per-package factors: description, platforms.
Verification factor: repository-verified.
The Verified by Swamp badge is not a factor and does not appear in the
breakdown. It is evaluated from the extension's namespace and the admin-set
verifiedBySwamp flag on the latest version meta.
Rubric stability
Extensions published under an older rubric version are not re-analysed when the rubric changes. Their stored per-version meta is composed on each page load under the rubric version it was scored with: its factors, and its divisor. A new publish or a rescore analyses the version again under the current rubric.
Related
- Extension Manifest — YAML schema for
the
description,repository,platforms, andadditionalFilesfields evaluated by the rubric. - About the Extension Scorecard — what the score is and why it exists.
- Improve Your Extension Score — per-factor steps to raise the score.