From 0efeac7a7b5c6cdef6d404ba966aaff613406d17 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Tue, 22 Sep 2026 13:54:56 +0000 Subject: [PATCH 1/2] S0349-astm-10: publish a dependency inventory with each release Produce a CycloneDX inventory of what a build was made from, refuse it unless it is true of that build, and attach it to the version's GitHub release as a downloadable asset. scripts/check-dependency-inventory.ts reads the package identity from package.json and the resolved set from pnpm-lock.yaml, writes the artifact into the gitignored dist-artifacts/, then grades it: missing, empty, unparseable, identity-less, version-mismatched or short of a resolved dependency are each refused by name with a non-zero status, and a produce-side refusal removes any stale artifact rather than leaving one to be read as this build's. The guard runs in prepublishOnly, before the registry upload, so a bad inventory costs a failed run instead of a published version nothing describes. The attach is a second job in this repo's release workflow, because the shared pipeline attaches a fixed two-name asset list and declares no workflow_call outputs; it runs after the publish because the release does not exist before one, finds the release from the tag at the published commit, and fails its run rather than skipping quietly when the asset does not arrive. No runtime source changes, so no per-directory coverage gate moves and no exported value, type or emitted byte is touched. --- ...-dependency-inventory-with-each-release.md | 23 + .github/workflows/release.yml | 76 +++ CHANGELOG.md | 40 +- documentation/release-readiness.md | 39 +- package.json | 5 +- scripts/check-dependency-inventory.ts | 491 ++++++++++++++++++ test/dependency-inventory.test.ts | 424 +++++++++++++++ 7 files changed, 1078 insertions(+), 20 deletions(-) create mode 100644 .changeset/publish-a-dependency-inventory-with-each-release.md create mode 100644 scripts/check-dependency-inventory.ts create mode 100644 test/dependency-inventory.test.ts diff --git a/.changeset/publish-a-dependency-inventory-with-each-release.md b/.changeset/publish-a-dependency-inventory-with-each-release.md new file mode 100644 index 0000000..49207e4 --- /dev/null +++ b/.changeset/publish-a-dependency-inventory-with-each-release.md @@ -0,0 +1,23 @@ +--- +"@cosyte/astm": patch +--- + +Publish a machine-readable dependency inventory with each release. + +Every published version now carries a CycloneDX inventory of what that build was made from, attached +to its GitHub release as a downloadable asset. The inventory names this package and the version +being built alongside every dependency that build resolved, runtime and build-time alike, +transitively, each with the exact version it resolved to. A team evaluating this package for a +regulated laboratory can now read what it is built from and feed it to their own tooling, instead of +taking a sentence about dependencies on trust or reproducing an install to find out. + +It is produced and checked by the repository itself, from the checkout, with no registry credential. +The check runs before the registry upload: a version whose inventory cannot be produced, or which +does not truthfully describe the build, fails the release rather than publishing with nothing to +describe it. + +What the inventory does not claim. It states what was resolved and nothing about whether that is +safe: it is not a vulnerability scan, a licence assessment, an attestation or a signature, and npm +provenance remains the separate thing it already was. It carries component names and versions only. + +No exported value, type or behaviour changes, and no byte is emitted differently. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c68c258..2b51203 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,11 @@ name: Release # Thin caller of the shared release pipeline: Changesets -> npm publish (provenance auto-enables when # this repo is public) -> docs artifacts -> GitHub release -> repository_dispatch to cosyte/docs. # Requires the NPM_TOKEN and DOCS_REPO_DISPATCH_TOKEN secrets (passed via `secrets: inherit`). +# +# The second job attaches this package's dependency inventory to that release. It is a job HERE +# rather than a step there because the shared pipeline attaches a fixed two-name asset list +# (docs-content.tar.gz, source.tar.gz) and a third file in dist-artifacts/ is not picked up, so an +# asset this repo wants on its own release page has to be uploaded by this repo. on: push: @@ -23,3 +28,74 @@ jobs: with: package-name: "@cosyte/astm" secrets: inherit + + # Put the dependency inventory on the release page as its own downloadable asset. + # + # WHY IT RUNS AFTER THE PUBLISH, which is the half that decides what a bad inventory costs. The + # GitHub release this uploads to does not exist until the pipeline above has published and cut + # it, so nothing here can run earlier. That is exactly why this job is NOT where the inventory is + # guarded: `prepublishOnly` in package.json runs `pnpm check:dependency-inventory` BEFORE the + # registry upload, so an inventory that cannot be produced or is not true of the build aborts the + # publish and the version never becomes public. This job can only fail a run, and a failed run is + # recoverable; a published version nothing describes is not. + # + # The pipeline above declares no `workflow_call` outputs, so the release is found from the repo + # rather than read from it: the tag Changesets wrote for this version, at this commit. + attach-dependency-inventory: + needs: release + runs-on: ubuntu-latest + permissions: + contents: write # upload an asset to the release the shared pipeline cut + steps: + # Actions are pinned to a commit rather than to a tag that can move. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 # tags are how this job finds the release; a shallow clone carries none + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + # Produced fresh from this commit, and refused unless it is true of this build. Generation is + # deterministic (no timestamp, no serial number), so this is the artifact prepublishOnly + # already checked on the other side of the publish, rebuilt rather than carried across jobs. + - run: pnpm check:dependency-inventory + - name: Attach the inventory to the release this push published + env: + GH_TOKEN: ${{ github.token }} + ASSET: dist-artifacts/dependency-inventory.cdx.json + run: | + set -euo pipefail + version="$(node -p "require('./package.json').version")" + head_sha="$(git rev-parse HEAD)" + # Shape-agnostic: whatever tag carrying this version points at the published commit. + mapfile -t candidates < <(git tag --points-at HEAD | grep -F "$version" || true) + if [ "${#candidates[@]}" -eq 0 ]; then + echo "No tag carrying version $version points at $head_sha." + echo "This push opened or updated a version PR rather than publishing, so there is" + echo "no release to attach an inventory to." + exit 0 + fi + tag="" + for candidate in "${candidates[@]}"; do + if gh release view "$candidate" >/dev/null 2>&1; then + tag="$candidate" + break + fi + done + if [ -z "$tag" ]; then + echo "::error::version $version is tagged at $head_sha (${candidates[*]}) but no GitHub release exists for any of those tags, so the inventory cannot be attached." + exit 1 + fi + # --clobber is what makes a re-run attach to the SAME release without republishing: + # re-running this job alone uploads over the asset and never touches the registry. + gh release upload "$tag" "$ASSET" --clobber + # A silent skip is the failure mode this guards. The asset is read back from the release + # and its absence fails the run that attempted the attach. + asset_name="$(basename "$ASSET")" + if ! gh release view "$tag" --json assets --jq '.assets[].name' | grep -Fqx "$asset_name"; then + echo "::error::$asset_name is not listed on release $tag after the upload reported success." + exit 1 + fi + echo "Attached $asset_name to release $tag." diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f7323b..e5e586e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,15 @@ this file is maintained by hand (Changesets handles the version bump and publish ## [Unreleased] **The pending changeset set is classified `minor`, not `patch`, so the next release is a minor -release.** Six changesets are pending and four of them arrived carrying `patch`. Three of those -four were reclassified against their own text: one removes public values and changes what an +release.** Seven changesets are pending and five of them arrived carrying `patch`. Three of those +five were reclassified against their own text: one removes public values and changes what an exported function returns, and two add public values. None of the three is a fix that adds nothing and removes nothing, which is the only thing a `patch` may claim, and only those three bump lines -moved. The fourth keeps `patch`: it changes repository tooling and install configuration, touches no -published value and emits no byte differently, so `patch` is what its own text supports. The other -two arrived carrying `minor` already, each written against this same rule, so both are applied as -written rather than reclassified. A set carrying a `patch` beside a `minor` still resolves to the +moved. The other two keep `patch`: one changes repository tooling and install configuration, the +other changes what a release carries beside the package, and neither touches a published value or +emits a byte differently, so `patch` is what each one's own text supports. The last two arrived +carrying `minor` already, each written against this same rule, so both are applied as written rather +than reclassified. A set carrying a `patch` beside a `minor` still resolves to the minor channel. The breaking change below therefore ships in the minor channel of the pre-1.0 ladder, which is where @@ -30,13 +31,38 @@ mirrors it. The entries the pending set carries are the date conversion surface entry, the LIVD units entry, the LIVD publication metadata entry and the vocabulary-attribution entry under Added, and the LIVD -catalog entry and the PHI scanner entry under Changed. This section also holds entries written +catalog entry, the PHI scanner entry and the dependency inventory entry under Changed. This section +also holds entries written before them, which is where they have always been kept: no released entry was moved, reworded or deleted by the reclassification, because rewriting a shipped changelog destroys the traceability it exists for. ### Changed +- **Every published release now carries a machine-readable dependency inventory as a downloadable + asset.** No published value, type, warning code or emitted byte moves: this entry is release + infrastructure only, and nothing under `src/` is touched. The inventory is a CycloneDX document + naming this package and the version being built alongside every dependency that build resolved, + runtime and build-time alike, transitively, each with the exact version it resolved to. It is read + from the lockfile rather than from the manifest's two lists, because the manifest states ranges + while a build resolves versions, so the artifact answers the question a reader of a dependency + claim actually has. A team evaluating this package for a regulated laboratory can download it from + the release page and feed it to their own tooling without cloning anything or reproducing an + install. `scripts/check-dependency-inventory.ts` both produces and grades it, from the checkout, + with no registry credential and no network call, and it is generated into the gitignored + `dist-artifacts/` rather than committed, so it can never describe a version other than the one it + ships beside. The grading runs in `prepublishOnly`, which is BEFORE the registry upload: an + inventory that cannot be produced at all, or that is empty, unparseable, missing this package's + identity or version, or short of a dependency the build resolved, is refused by name and aborts + the publish, so a bad inventory costs a failed run rather than a published version nothing + describes. A refusal also removes any older artifact at that path rather than leaving one to be + read as this build's. Attaching it to the release is a second job in the release workflow, because + the shared pipeline attaches a fixed two-name asset list; that job runs after the publish, since + the release does not exist before one, and it fails its run rather than skipping quietly if the + asset does not arrive. What the inventory does not claim: it states what was resolved and nothing + about whether that is safe. It is not a vulnerability scan, a licence assessment, an attestation + or a signature, it carries component names and versions only, and npm provenance remains the + separate thing it already was. - **The PHI scanner refuses a run that enumerated a target and never read it, and the install settings that hold a resolution back are now in force rather than merely written down.** No published value, type, warning code or emitted byte moves: this entry is repository tooling and diff --git a/documentation/release-readiness.md b/documentation/release-readiness.md index d1f5383..5690b3e 100644 --- a/documentation/release-readiness.md +++ b/documentation/release-readiness.md @@ -26,14 +26,14 @@ The published version is the `version` field of `package.json`, `0.0.22`, on the ladder. The pending set below is every file matching `.changeset/*.md` other than `README.md` (`config.json` is the tool's configuration and carries no bump). -Four of the six pending changesets arrived classified `patch`. Three of those four are reclassified -to `minor` here, each for a reason taken from its own text; their bump lines are the only lines that -were rewritten, and no changeset's prose was edited. The fourth arrived `patch` and stays `patch`, -because its own text removes no public value, adds none, and changes no exported behaviour: -reclassification runs in both directions or it is not a reading, and a tooling change written up as -a feature would overstate the release exactly as a removal written up as a fix understates one. The -other two arrived carrying `minor` already, each written against this same rule, so both are applied -as written rather than reclassified. +Five of the seven pending changesets arrived classified `patch`, and two arrived carrying `minor`. +Three of the five are reclassified to `minor` here, each for a reason taken from its own text; their +bump lines are the only lines that were rewritten, and no changeset's prose was edited. The other +two stay `patch`, because neither one's text removes a public value, adds one, or changes any +exported behaviour: reclassification runs in both directions or it is not a reading, and a tooling +change written up as a feature would overstate the release exactly as a removal written up as a fix +understates one. The two that arrived carrying `minor` were each written against this same rule, so +both are applied as written rather than reclassified. @@ -43,15 +43,16 @@ as written rather than reclassified. | `livd-catalog-answers-the-analyte.md` | `patch` | `minor` | | `livd-catalog-says-which-loinc-version.md` | `minor` | `minor` | | `livd-units-choose-between-candidates.md` | `patch` | `minor` | +| `publish-a-dependency-inventory-with-each-release.md` | `patch` | `patch` | | `say-which-vocabulary-a-letter-was-graded-against.md` | `patch` | `minor` | | `shared-date-conversion-surface.md` | `minor` | `minor` | Highest bump in the set: `minor`. Applied to the published `0.0.22`, Changesets resolves -`@cosyte/astm` to **`0.1.0`**. The `patch` above does not move that: a `patch` beside a `minor` -changes nothing about the resolved version, and it is recorded rather than dropped because a -pending changeset absent from this table is what section 1 exists to make impossible. +`@cosyte/astm` to **`0.1.0`**. Neither `patch` above moves that: a `patch` beside a `minor` changes +nothing about the resolved version, and both are recorded rather than dropped because a pending +changeset absent from this table is what section 1 exists to make impossible. ### `livd-catalog-answers-the-analyte.md`: `minor` @@ -153,6 +154,22 @@ That is what a `patch` claims, so `patch` is what it keeps. The reclassification section is a reading of each changeset's own text, and a reading that only ever moved a bump upward would not be one. +### `publish-a-dependency-inventory-with-each-release.md`: `patch` + +Its own text adds `scripts/check-dependency-inventory.ts`, a package script that runs it, a second +job in `.github/workflows/release.yml`, and one clause on the end of `prepublishOnly`. What a +consumer gains is an asset on the release page, which is release infrastructure rather than package +surface: the entry point re-exports nothing from `scripts/`, no `src/` file is touched, and the +inventory is generated into the gitignored `dist-artifacts/` rather than committed or shipped in the +tarball. Measured against section 3's enumeration, it adds no value, removes none, renames none, and +changes no exported function's behaviour. It emits no byte differently: a parsed and re-serialized +record is unchanged, and no warning code moves. + +The one thing it does change for a consumer is what a published release carries beside the package, +and that is the whole reason it takes a changeset at all rather than riding a release silently. A +change to what ships with a release is still a `patch` when the published values are untouched, so +`patch` is what it keeps. + ### `shared-date-conversion-surface.md`: `minor` Its own text is headed "Added", and everything under that heading is public: the exported functions diff --git a/package.json b/package.json index 184f3fd..912f174 100644 --- a/package.json +++ b/package.json @@ -64,8 +64,9 @@ "changeset": "changeset", "typecheck": "tsc --noEmit", "phi-scan": "tsx scripts/phi-scan.ts", - "check": "pnpm run check:gate-coverage && pnpm run check:agent-notes", + "check": "pnpm run check:gate-coverage && pnpm run check:agent-notes && pnpm run check:dependency-inventory", "check:agent-notes": "tsx scripts/check-agent-notes.ts", + "check:dependency-inventory": "tsx scripts/check-dependency-inventory.ts", "check:gate-coverage": "tsx scripts/check-gate-coverage.ts", "check:no-emdash": "bash scripts/check-no-emdash.sh", "check:no-internal-refs": "bash scripts/check-no-internal-refs.sh", @@ -83,7 +84,7 @@ "smoke": "node scripts/smoke.mjs", "version": "changeset version && node scripts/sync-version.mjs && prettier --write package.json CHANGELOG.md src/index.ts", "release": "changeset publish", - "prepublishOnly": "pnpm clean && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm attw", + "prepublishOnly": "pnpm clean && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm attw && pnpm check:dependency-inventory", "prepare": "command -v simple-git-hooks >/dev/null 2>&1 && simple-git-hooks || true" }, "simple-git-hooks": { diff --git a/scripts/check-dependency-inventory.ts b/scripts/check-dependency-inventory.ts new file mode 100644 index 0000000..823ff02 --- /dev/null +++ b/scripts/check-dependency-inventory.ts @@ -0,0 +1,491 @@ +#!/usr/bin/env tsx +/** + * `@cosyte/astm` dependency inventory: produce one for the build in this checkout, + * then refuse it unless it is a truthful description of that build. + * + * =========================================================================== + * WHY THIS EXISTS + * =========================================================================== + * + * A consumer evaluating this package for a regulated lab can read a + * zero-dependency sentence in prose and has no way to check it. Prose is not + * checkable and it is not machine-readable, and the sentence is about the + * RUNTIME set while a build resolves a much larger one. This script replaces + * the sentence with an artifact: a documented, parseable statement of what this + * version was built from, produced by the repo itself and published beside the + * release so nobody has to clone anything to read it. + * + * WHAT IT ASSERTS, and each one is a condition that has to be able to fire: + * + * - the inventory can be produced AT ALL. When it cannot, this exits non-zero + * and leaves NO artifact behind, including a stale one from an earlier run. + * Half an inventory is worse than none, because a lab reads it as complete; + * - the inventory is not empty, parses, names this package and the version + * being built, and that version is the one the manifest carries. A stale + * artifact describing the previous version is the failure this catches; + * - the inventory omits no dependency this build resolves. The set is read + * from the lockfile rather than from the manifest's two lists, because the + * manifest states RANGES and a build resolves versions. + * + * WHAT IT DOES NOT ASSERT, stated so its green is not read as wider than it is: + * + * - nothing about whether the dependency set is SAFE. This states what was + * resolved. It is not a scan, a licence policy, an attestation or a + * signature, and a green run says nothing about any of them; + * - nothing about a component the inventory carries that the lockfile does + * not. Over-reporting is a different defect with a different fix, and the + * refusal here is scoped to omission on purpose; + * - nothing about a lockfile format this parser has not been read against. + * An unsupported `lockfileVersion` is refused rather than guessed at. + * + * WHERE IT RUNS, which is the half that decides whether a refusal costs a + * failed run or a published version nothing describes: + * + * - `prepublishOnly`, which npm runs BEFORE the registry upload. A refusal + * there aborts the publish and the version never becomes public; + * - `pnpm check`, so the same production and the same refusal are reachable + * from a checkout with no credential and nothing published; + * - the release workflow's attach job, which runs AFTER the publish because + * the GitHub release it uploads to does not exist before one. That job can + * only fail a run, never unpublish, which is exactly why the guard above + * is not left to it. + * + * NETWORK AND CREDENTIALS: none. Two files are read, one is written, no + * subprocess is spawned and no registry is contacted, so the same run works in + * a release job and on a laptop offline. + * + * Usage: + * tsx scripts/check-dependency-inventory.ts produce, then verify + * tsx scripts/check-dependency-inventory.ts --verify-only verify what is on disk + * + * Exit codes: 0 (produced and verified), 1 (could not produce, or refused). + */ + +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Derived from this file's own location, never `process.cwd()`. */ +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const MANIFEST_PATH = join(REPO_ROOT, "package.json"); +const LOCKFILE_PATH = join(REPO_ROOT, "pnpm-lock.yaml"); +const OUT_DIR = join(REPO_ROOT, "dist-artifacts"); + +/** + * The artifact. `dist-artifacts/` is gitignored and is the same directory the + * release pipeline builds the docs bundles into, so the inventory is produced + * fresh for the build that publishes and is never committed, which is what + * keeps it from describing a version other than the one it ships beside. + */ +const OUT_PATH = join(OUT_DIR, "dependency-inventory.cdx.json"); + +/** + * The documented format the artifact is written in, recorded INSIDE it so a + * consumer needs no out-of-band knowledge of how it was made. CycloneDX is an + * ECMA standard (ECMA-424) with a published JSON schema per spec version, and + * both fields below are that schema's own identifying keys. + */ +const BOM_FORMAT = "CycloneDX"; +const BOM_SPEC_VERSION = "1.6"; + +/** + * pnpm lockfile versions whose `packages:` section this parser has actually been + * read against. An unlisted one is REFUSED rather than parsed hopefully: a + * format change that moved or re-keyed that section would otherwise produce a + * confidently short inventory, which is the one outcome worse than no inventory. + */ +const SUPPORTED_LOCKFILE_VERSIONS: readonly string[] = ["9.0"]; + +/** One resolved package: the only two facts about it that this artifact carries. */ +interface Component { + readonly name: string; + readonly version: string; +} + +/** The identity of the package being built, read from the manifest it publishes from. */ +interface Manifest { + readonly name: string; + readonly version: string; +} + +/** A condition that stops the run, carrying the name a reader has to act on. */ +class Refusal extends Error { + readonly condition: string; + + constructor(condition: string, detail: string) { + super(`${condition}: ${detail}`); + this.name = "Refusal"; + this.condition = condition; + } +} + +/** A path as a reader of a public CI log should see it: relative to the repo, never absolute. */ +function rel(path: string): string { + return relative(REPO_ROOT, path) || path; +} + +/** + * A package URL for an npm component. + * + * It is name and version re-encoded and carries no further fact, which is why + * it is safe to publish: it is the identifier an advisory database or an SBOM + * reader matches on, so omitting it would cost a consumer real work for nothing. + */ +function purlFor(name: string, version: string): string { + const slash = name.lastIndexOf("/"); + const namespace = slash === -1 ? "" : name.slice(0, slash); + const bare = slash === -1 ? name : name.slice(slash + 1); + const prefix = namespace === "" ? "" : `${encodeURIComponent(namespace)}/`; + return `pkg:npm/${prefix}${encodeURIComponent(bare)}@${encodeURIComponent(version)}`; +} + +/** The manifest's identity, refusing anything this artifact would have to guess at. */ +function readManifest(path: string): Manifest { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch { + throw new Refusal("manifest", `cannot read ${rel(path)}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Refusal("manifest", `${rel(path)} is not valid JSON`); + } + if (typeof parsed !== "object" || parsed === null) { + throw new Refusal("manifest", `${rel(path)} did not parse to an object`); + } + const { name, version } = parsed as { name?: unknown; version?: unknown }; + if (typeof name !== "string" || name.length === 0) { + throw new Refusal("manifest", `${rel(path)} carries no package name`); + } + if (typeof version !== "string" || version.length === 0) { + throw new Refusal("manifest", `${rel(path)} carries no package version`); + } + return { name, version }; +} + +/** A `packages:` key, in each of the three ways this lockfile format writes one. */ +const PACKAGE_KEY = /^ {2}(?:'([^']+)'|"([^"]+)"|([^\s#'"][^:]*)):\s*$/; + +/** A resolved version, as the `packages:` section spells one. */ +const RESOLVED_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/; + +/** + * Split a `packages:` key into a name and the version resolved for it. + * + * The split is on the LAST `@`, because a scoped name opens with one. A key + * whose tail is not a plain resolved version is REFUSED rather than carried + * through: a git, tarball or aliased resolution writes something else there, + * and reporting it as a version would put a wrong fact on a public page. + */ +function splitPackageKey(id: string): Component { + const at = id.lastIndexOf("@"); + if (at <= 0) { + throw new Refusal("lockfile", `\`${id}\` in \`packages:\` is not a name and a version`); + } + const name = id.slice(0, at); + const version = id.slice(at + 1); + if (!RESOLVED_VERSION.test(version)) { + throw new Refusal( + "lockfile", + `\`${id}\` in \`packages:\` does not resolve to a plain version, so this build ` + + "resolves something this inventory cannot state truthfully", + ); + } + return { name, version }; +} + +/** + * Every package this build resolves, read from the lockfile rather than from the + * manifest. + * + * The manifest states RANGES and two lists; the lockfile states what those + * ranges actually resolved to, transitively, runtime and build-time alike. That + * is what "every dependency the build resolves" means, and it is the set a + * consumer would have to reproduce an install to see. + */ +function readResolvedComponents(path: string): readonly Component[] { + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch { + throw new Refusal("lockfile", `cannot read ${rel(path)}`); + } + + let lockfileVersion: string | undefined; + let section = ""; + let sawPackages = false; + const components: Component[] = []; + + for (const line of text.split(/\r?\n/)) { + const top = /^([A-Za-z][A-Za-z0-9_-]*):(.*)$/.exec(line); + if (top) { + section = top[1] ?? ""; + if (section === "lockfileVersion") { + lockfileVersion = (top[2] ?? "").trim().replace(/^['"]|['"]$/g, ""); + } + if (section === "packages") sawPackages = true; + continue; + } + if (section !== "packages") continue; + const key = PACKAGE_KEY.exec(line); + if (!key) continue; + components.push(splitPackageKey(key[1] ?? key[2] ?? key[3] ?? "")); + } + + if (lockfileVersion === undefined) { + throw new Refusal("lockfile", `${rel(path)} declares no \`lockfileVersion\``); + } + if (!SUPPORTED_LOCKFILE_VERSIONS.includes(lockfileVersion)) { + throw new Refusal( + "lockfile", + `${rel(path)} is lockfileVersion ${lockfileVersion}, and this script has only been read ` + + `against ${SUPPORTED_LOCKFILE_VERSIONS.join(", ")}. Read the new format's \`packages:\` ` + + "section and add the version here deliberately, rather than parsing it hopefully.", + ); + } + if (!sawPackages) { + throw new Refusal("lockfile", `${rel(path)} has no \`packages:\` section`); + } + if (components.length === 0) { + throw new Refusal("lockfile", `${rel(path)} resolves no packages at all`); + } + + return [...components].sort((a, b) => + a.name === b.name ? a.version.localeCompare(b.version) : a.name.localeCompare(b.name), + ); +} + +/** One entry as the artifact writes it. */ +interface BomComponent { + readonly type: "library"; + readonly "bom-ref": string; + readonly name: string; + readonly version: string; + readonly purl: string; +} + +/** The whole artifact. Every field here is published, so there are no others. */ +interface Bom { + readonly bomFormat: string; + readonly specVersion: string; + readonly version: number; + readonly metadata: { readonly component: BomComponent }; + readonly components: readonly BomComponent[]; +} + +function bomComponentFor({ name, version }: Component): BomComponent { + const purl = purlFor(name, version); + return { type: "library", "bom-ref": purl, name, version, purl }; +} + +/** + * The artifact, and deliberately nothing more than the artifact. + * + * FIELD DISCIPLINE, and it is a safety rule rather than a tidiness one. This + * ships on a PUBLIC release page, so every field is a field a stranger reads. + * Only the package's own identity, the resolved component names and their + * versions go in. No timestamp, no serial number, no producing-tool record, no + * author, no path and no dependency graph: the first two would make a + * byte-identical rebuild impossible to check, and the rest would put facts + * about the machine or the tree on a page that needs component names and + * versions and nothing else. + */ +function buildBom(pkg: Manifest, resolved: readonly Component[]): Bom { + return { + bomFormat: BOM_FORMAT, + specVersion: BOM_SPEC_VERSION, + version: 1, + metadata: { component: bomComponentFor(pkg) }, + components: resolved.map(bomComponentFor), + }; +} + +/** One way a produced inventory fails to describe the build it claims to describe. */ +interface Failure { + readonly condition: string; + readonly detail: string; +} + +/** + * Every named component in a document that has already parsed, or `undefined` + * when its `components` key is not the array this format requires. + */ +function componentsIn(document: Record): Component[] | undefined { + const raw = document["components"]; + if (!Array.isArray(raw)) return undefined; + const out: Component[] = []; + for (const entry of raw) { + if (typeof entry !== "object" || entry === null) return undefined; + const { name, version } = entry as { name?: unknown; version?: unknown }; + if (typeof name !== "string" || typeof version !== "string") return undefined; + out.push({ name, version }); + } + return out; +} + +/** + * Grade an inventory on disk against the build it claims to describe. + * + * Returns every condition that failed, because an artifact can fail more than + * one and naming only the first would cost a second run to find the second. + * The conditions are ordered so a later one is never reported off a document + * the earlier one already showed cannot be read. + */ +function verifyInventory(path: string, pkg: Manifest, resolved: readonly Component[]): Failure[] { + if (!existsSync(path)) { + return [{ condition: "missing", detail: `no inventory at ${rel(path)}` }]; + } + + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch { + return [{ condition: "missing", detail: `the inventory at ${rel(path)} cannot be read` }]; + } + if (raw.trim().length === 0) { + return [{ condition: "empty", detail: `the inventory at ${rel(path)} has no content` }]; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return [ + { condition: "unparseable", detail: `the inventory at ${rel(path)} is not valid JSON` }, + ]; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return [ + { condition: "unparseable", detail: "the inventory did not parse to a document object" }, + ]; + } + const document = parsed as Record; + if (document["bomFormat"] !== BOM_FORMAT || document["specVersion"] !== BOM_SPEC_VERSION) { + return [ + { + condition: "unparseable", + detail: + "the inventory does not declare itself as " + + `${BOM_FORMAT} ${BOM_SPEC_VERSION}, so there is no documented format to read it as`, + }, + ]; + } + + const failures: Failure[] = []; + + const listed = componentsIn(document); + if (listed === undefined) { + failures.push({ + condition: "unparseable", + detail: "`components` is not a list of entries carrying a name and a version", + }); + } else if (listed.length === 0) { + failures.push({ condition: "empty", detail: "the inventory lists no components at all" }); + } + + const metadata = document["metadata"]; + const subject = + typeof metadata === "object" && metadata !== null + ? (metadata as { component?: unknown }).component + : undefined; + const identity = + typeof subject === "object" && subject !== null + ? (subject as { name?: unknown; version?: unknown }) + : { name: undefined, version: undefined }; + + if (identity.name !== pkg.name) { + failures.push({ + condition: "identity", + detail: + "the inventory does not name this package as its subject: expected " + + `\`${pkg.name}\`, found ${JSON.stringify(identity.name) ?? "nothing"}`, + }); + } + if (identity.version !== pkg.version) { + failures.push({ + condition: "version", + detail: + "the inventory does not carry the version being built: the manifest publishes " + + `\`${pkg.version}\`, the inventory says ${JSON.stringify(identity.version) ?? "nothing"}`, + }); + } + + if (listed !== undefined) { + const present = new Set(listed.map((c) => `${c.name}@${c.version}`)); + const omitted = resolved + .filter((c) => !present.has(`${c.name}@${c.version}`)) + .map((c) => `${c.name}@${c.version}`); + if (omitted.length > 0) { + const shown = omitted.slice(0, 10).join(", "); + const rest = omitted.length > 10 ? `, and ${String(omitted.length - 10)} more` : ""; + failures.push({ + condition: "incomplete", + detail: + `the build resolves ${String(omitted.length)} dependency/dependencies the inventory ` + + `omits: ${shown}${rest}`, + }); + } + } + + return failures; +} + +/** Report a produce-side stop, and make sure nothing is left on disk in its place. */ +function stopWithoutArtifact(message: string, removeArtifact: boolean): number { + process.stderr.write(`\n[dependency-inventory] cannot produce the inventory.\n ${message}\n`); + if (removeArtifact && existsSync(OUT_PATH)) { + rmSync(OUT_PATH, { force: true }); + process.stderr.write( + ` removed the stale artifact at ${rel(OUT_PATH)}: an inventory that could not be ` + + "produced must not be replaced by an older one that would be read as this build's.\n", + ); + } + return 1; +} + +function main(argv: readonly string[]): number { + const verifyOnly = argv.includes("--verify-only"); + + let pkg: Manifest; + let resolved: readonly Component[]; + try { + pkg = readManifest(MANIFEST_PATH); + resolved = readResolvedComponents(LOCKFILE_PATH); + } catch (error) { + if (!(error instanceof Refusal)) throw error; + return stopWithoutArtifact(error.message, !verifyOnly); + } + + if (!verifyOnly) { + try { + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(OUT_PATH, `${JSON.stringify(buildBom(pkg, resolved), null, 2)}\n`, "utf8"); + } catch { + return stopWithoutArtifact(`the inventory could not be written to ${rel(OUT_PATH)}`, true); + } + process.stdout.write( + `[dependency-inventory] wrote ${rel(OUT_PATH)}: ${BOM_FORMAT} ${BOM_SPEC_VERSION}, ` + + `${pkg.name} ${pkg.version}, ${String(resolved.length)} resolved component(s).\n`, + ); + } + + const failures = verifyInventory(OUT_PATH, pkg, resolved); + if (failures.length > 0) { + process.stderr.write( + `\n[dependency-inventory] REFUSED: ${String(failures.length)} condition(s) failed.\n` + + `${failures.map((f) => ` - ${f.condition}: ${f.detail}`).join("\n")}\n`, + ); + return 1; + } + + process.stdout.write( + `[dependency-inventory] OK: ${rel(OUT_PATH)} states ${pkg.name} ${pkg.version} and every ` + + `one of the ${String(resolved.length)} dependency/dependencies this build resolves.\n`, + ); + return 0; +} + +process.exit(main(process.argv.slice(2))); diff --git a/test/dependency-inventory.test.ts b/test/dependency-inventory.test.ts new file mode 100644 index 0000000..3a14d7a --- /dev/null +++ b/test/dependency-inventory.test.ts @@ -0,0 +1,424 @@ +/** + * The dependency inventory: `scripts/check-dependency-inventory.ts`. + * + * The inventory is the answer to a consumer who cannot check a zero-dependency + * sentence written in prose, and it goes on a PUBLIC release page. So a check + * that can only pass is worth nothing here: every case below either drives the + * real script RED on a tree carrying the defect it names, or reads the artifact + * it actually produced. + * + * Each case builds a THROWAWAY tree carrying its own copy of the script, its own + * `package.json` and its own `pnpm-lock.yaml`, because the script derives its + * repo from its own file location. The cases that read this package's own + * inventory run the real script on the real tree and assert on what came out. + * Nothing here stands a double in for the script: the subject always runs. + * + * SECURITY: every subprocess call uses spawnSync with array args. No shell form. + */ + +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, describe, expect, it } from "vitest"; + +const REPO_ROOT = process.cwd(); +const SCRIPT_NAME = "check-dependency-inventory.ts"; +const SCRIPT_PATH = join(REPO_ROOT, "scripts", SCRIPT_NAME); +const TSX_BIN = join(REPO_ROOT, "node_modules", ".bin", "tsx"); +const ARTIFACT_RELATIVE = join("dist-artifacts", "dependency-inventory.cdx.json"); + +/** The documented format the artifact declares, as a consumer would look for it. */ +const BOM_FORMAT = "CycloneDX"; +const BOM_SPEC_VERSION = "1.6"; + +const trees: string[] = []; + +afterAll(() => { + for (const t of trees) rmSync(t, { recursive: true, force: true }); +}); + +interface RunResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + +/** A pnpm lockfile in the shape this repo's own carries, resolving exactly `ids`. */ +function lockfile(ids: readonly string[], lockfileVersion = "9.0"): string { + const packages = ids.flatMap((id) => [ + ` '${id}':`, + " resolution: {integrity: sha512-0000000000000000000000000000000000000000000000000000000000000000000000000000000000000==}", + "", + ]); + // A `snapshots:` section is present in every real lockfile and repeats every id. The parser + // reads `packages:` alone, so its presence here is what shows a component is not counted twice. + const snapshots = ids.flatMap((id) => [` '${id}': {}`, ""]); + return [ + `lockfileVersion: '${lockfileVersion}'`, + "", + "settings:", + " autoInstallPeers: true", + "", + "importers:", + "", + " .:", + " devDependencies:", + " example: {}", + "", + "packages:", + "", + ...packages, + "snapshots:", + "", + ...snapshots, + ].join("\n"); +} + +interface TreeOptions { + readonly manifest?: string; + readonly lock?: string; + /** An inventory to place at the artifact path before the run, for the verify cases. */ + readonly artifact?: string | undefined; +} + +const DEFAULT_MANIFEST = JSON.stringify({ name: "@example/widget", version: "1.2.3" }, null, 2); +const DEFAULT_IDS = ["@example/scoped-dep@0.4.1", "leaf@2.0.0", "transitive-only@1.9.12"]; + +/** + * A throwaway tree with its own copy of the script. A manifest, a lockfile and + * optionally an artifact are the whole fixture, because those are exactly the + * three files the script reconciles. + */ +function makeTree(options: TreeOptions = {}): string { + const root = mkdtempSync(join(tmpdir(), "dependency-inventory-")); + trees.push(root); + mkdirSync(join(root, "scripts")); + copyFileSync(SCRIPT_PATH, join(root, "scripts", SCRIPT_NAME)); + if (options.manifest !== undefined) { + writeFileSync(join(root, "package.json"), options.manifest); + } else { + writeFileSync(join(root, "package.json"), DEFAULT_MANIFEST); + } + if (options.lock !== undefined) writeFileSync(join(root, "pnpm-lock.yaml"), options.lock); + if (options.artifact !== undefined) { + mkdirSync(join(root, "dist-artifacts"), { recursive: true }); + writeFileSync(join(root, ARTIFACT_RELATIVE), options.artifact); + } + return root; +} + +function runIn(root: string, args: readonly string[] = []): RunResult { + const r = spawnSync(TSX_BIN, [join(root, "scripts", SCRIPT_NAME), ...args], { + cwd: root, + encoding: "utf8", + shell: false, + }); + return { code: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; +} + +interface BomComponent { + readonly name?: unknown; + readonly version?: unknown; + readonly purl?: unknown; + readonly type?: unknown; +} + +interface Bom { + readonly bomFormat?: unknown; + readonly specVersion?: unknown; + readonly metadata?: { readonly component?: BomComponent }; + readonly components?: readonly BomComponent[]; +} + +function readArtifact(root: string): Bom { + return JSON.parse(readFileSync(join(root, ARTIFACT_RELATIVE), "utf8")) as Bom; +} + +/** A sound inventory for the default fixture, which each AC-6 case then breaks in one way. */ +function soundArtifact(overrides: Record = {}): string { + const component = (name: string, version: string): Record => ({ + type: "library", + "bom-ref": `pkg:npm/${name}@${version}`, + name, + version, + purl: `pkg:npm/${name}@${version}`, + }); + return JSON.stringify( + { + bomFormat: BOM_FORMAT, + specVersion: BOM_SPEC_VERSION, + version: 1, + metadata: { component: component("@example/widget", "1.2.3") }, + components: DEFAULT_IDS.map((id) => { + const at = id.lastIndexOf("@"); + return component(id.slice(0, at), id.slice(at + 1)); + }), + ...overrides, + }, + null, + 2, + ); +} + +describe("AC-2: the inventory names this package and the version being built", () => { + it("[AC-2] names the package identity and version the manifest publishes from", () => { + const root = makeTree({ lock: lockfile(DEFAULT_IDS) }); + const r = runIn(root); + expect(r.code, `stderr: ${r.stderr}`).toBe(0); + const subject = readArtifact(root).metadata?.component; + expect(subject?.name).toBe("@example/widget"); + expect(subject?.version).toBe("1.2.3"); + }); + + it("[AC-2] carries the version the manifest carries, on this package's own tree", () => { + const r = runIn(makeTree({ lock: lockfile(DEFAULT_IDS) })); + expect(r.code).toBe(0); + const real = spawnSync(TSX_BIN, [SCRIPT_PATH], { cwd: REPO_ROOT, encoding: "utf8" }); + expect(real.status, `stderr: ${real.stderr ?? ""}`).toBe(0); + const manifest = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")) as { + version: string; + name: string; + }; + const bom = JSON.parse(readFileSync(join(REPO_ROOT, ARTIFACT_RELATIVE), "utf8")) as Bom; + expect(bom.metadata?.component?.name).toBe(manifest.name); + expect(bom.metadata?.component?.version).toBe(manifest.version); + }); +}); + +describe("AC-3: the inventory lists every dependency the build resolves", () => { + it("[AC-3] lists every package the lockfile resolves, each with its exact version", () => { + const root = makeTree({ lock: lockfile(DEFAULT_IDS) }); + expect(runIn(root).code).toBe(0); + const listed = (readArtifact(root).components ?? []).map( + (c) => `${String(c.name)}@${String(c.version)}`, + ); + expect(listed.sort()).toEqual([...DEFAULT_IDS].sort()); + }); + + it("[AC-3] counts a package once even though the lockfile repeats it in `snapshots:`", () => { + const root = makeTree({ lock: lockfile(DEFAULT_IDS) }); + expect(runIn(root).code).toBe(0); + expect(readArtifact(root).components).toHaveLength(DEFAULT_IDS.length); + }); + + it("[AC-3] reads the build-time set transitively, not the manifest's two lists", () => { + // This package's own tree: `dependencies` is empty and `devDependencies` is not, so a + // reading of "the manifest's two lists" would produce a handful of entries. The build + // resolves an order of magnitude more, and every direct devDependency is among them. + const real = spawnSync(TSX_BIN, [SCRIPT_PATH], { cwd: REPO_ROOT, encoding: "utf8" }); + expect(real.status, `stderr: ${real.stderr ?? ""}`).toBe(0); + const bom = JSON.parse(readFileSync(join(REPO_ROOT, ARTIFACT_RELATIVE), "utf8")) as Bom; + const manifest = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")) as { + dependencies?: Record; + devDependencies?: Record; + }; + const direct = [ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.devDependencies ?? {}), + ]; + const names = new Set((bom.components ?? []).map((c) => String(c.name))); + for (const name of direct) expect(names.has(name), `${name} is missing`).toBe(true); + expect((bom.components ?? []).length).toBeGreaterThan(direct.length * 2); + for (const component of bom.components ?? []) { + expect(String(component.name).length).toBeGreaterThan(0); + expect(String(component.version)).toMatch(/^\d+\.\d+\.\d+/); + } + }); +}); + +describe("AC-4: the inventory says what format it is written in", () => { + it("[AC-4] records the documented format and its version inside the artifact", () => { + const root = makeTree({ lock: lockfile(DEFAULT_IDS) }); + expect(runIn(root).code).toBe(0); + const bom = readArtifact(root); + expect(bom.bomFormat).toBe(BOM_FORMAT); + expect(bom.specVersion).toBe(BOM_SPEC_VERSION); + }); + + it("[AC-4] gives every component a package URL, so the format is parseable with no local knowledge", () => { + const root = makeTree({ lock: lockfile(["@example/scoped-dep@0.4.1"]) }); + expect(runIn(root).code).toBe(0); + const [component] = readArtifact(root).components ?? []; + expect(component?.purl).toBe("pkg:npm/%40example/scoped-dep@0.4.1"); + expect(component?.type).toBe("library"); + }); +}); + +describe("AC-5: an inventory that cannot be produced leaves nothing behind", () => { + it("[AC-5] refuses with a non-zero status when the lockfile is absent", () => { + const root = makeTree(); + const r = runIn(root); + expect(r.code).toBe(1); + expect(r.stderr).toContain("cannot produce the inventory"); + expect(r.stderr).toContain("pnpm-lock.yaml"); + expect(existsSync(join(root, ARTIFACT_RELATIVE))).toBe(false); + }); + + it("[AC-5] refuses a lockfile format it has not been read against", () => { + const root = makeTree({ lock: lockfile(DEFAULT_IDS, "10.0") }); + const r = runIn(root); + expect(r.code).toBe(1); + expect(r.stderr).toContain("lockfileVersion 10.0"); + expect(existsSync(join(root, ARTIFACT_RELATIVE))).toBe(false); + }); + + it("[AC-5] refuses a lockfile that resolves nothing at all", () => { + const root = makeTree({ + lock: "lockfileVersion: '9.0'\n\nsettings:\n autoInstallPeers: true\n", + }); + const r = runIn(root); + expect(r.code).toBe(1); + expect(r.stderr).toContain("no `packages:` section"); + expect(existsSync(join(root, ARTIFACT_RELATIVE))).toBe(false); + }); + + it("[AC-5] refuses a resolution it cannot state as a plain version", () => { + // A workspace, link, git or tarball resolution writes something other than a version in that + // position. Carrying it through would put a wrong fact on a public page, so the run stops. + const root = makeTree({ lock: lockfile(["leaf@workspace:*"]) }); + const r = runIn(root); + expect(r.code).toBe(1); + expect(r.stderr).toContain("does not resolve to a plain version"); + expect(existsSync(join(root, ARTIFACT_RELATIVE))).toBe(false); + }); + + it("[AC-5] refuses a manifest with no version to build against", () => { + const root = makeTree({ + manifest: JSON.stringify({ name: "@example/widget" }), + lock: lockfile(DEFAULT_IDS), + }); + const r = runIn(root); + expect(r.code).toBe(1); + expect(r.stderr).toContain("carries no package version"); + expect(existsSync(join(root, ARTIFACT_RELATIVE))).toBe(false); + }); + + it("[AC-5] removes a stale artifact rather than leaving it to be read as this build's", () => { + // The release path attaches whatever is at the artifact path. A refusal that left an older + // inventory sitting there would publish it as this version's, which is the emit-in-place-of + // this criterion forbids. + const root = makeTree({ artifact: soundArtifact() }); + expect(existsSync(join(root, ARTIFACT_RELATIVE))).toBe(true); + const r = runIn(root); + expect(r.code).toBe(1); + expect(r.stderr).toContain("removed the stale artifact"); + expect(existsSync(join(root, ARTIFACT_RELATIVE))).toBe(false); + }); +}); + +describe("AC-6: a produced inventory that is not true of the build is refused", () => { + const lock = lockfile(DEFAULT_IDS); + + function verifyOnly(artifact: string | undefined): RunResult { + return runIn(makeTree({ lock, artifact }), ["--verify-only"]); + } + + it("[AC-6] refuses a missing inventory, and says so", () => { + const r = verifyOnly(undefined); + expect(r.code).toBe(1); + expect(r.stderr).toContain("missing:"); + }); + + it("[AC-6] refuses an inventory with no content", () => { + const r = verifyOnly(""); + expect(r.code).toBe(1); + expect(r.stderr).toContain("empty:"); + }); + + it("[AC-6] refuses an inventory that lists no components", () => { + const r = verifyOnly(soundArtifact({ components: [] })); + expect(r.code).toBe(1); + expect(r.stderr).toContain("empty:"); + }); + + it("[AC-6] refuses an inventory that does not parse", () => { + const r = verifyOnly("{ this is not json"); + expect(r.code).toBe(1); + expect(r.stderr).toContain("unparseable:"); + }); + + it("[AC-6] refuses a document that declares no format to read it as", () => { + const r = verifyOnly(soundArtifact({ bomFormat: "SomethingElse" })); + expect(r.code).toBe(1); + expect(r.stderr).toContain("unparseable:"); + }); + + it("[AC-6] refuses an inventory that omits this package's own identity", () => { + const r = verifyOnly(soundArtifact({ metadata: { component: { version: "1.2.3" } } })); + expect(r.code).toBe(1); + expect(r.stderr).toContain("identity:"); + }); + + it("[AC-6] refuses an inventory naming a different package as its subject", () => { + const r = verifyOnly( + soundArtifact({ metadata: { component: { name: "@example/other", version: "1.2.3" } } }), + ); + expect(r.code).toBe(1); + expect(r.stderr).toContain("identity:"); + }); + + it("[AC-6] refuses an inventory that omits the version being built", () => { + const r = verifyOnly(soundArtifact({ metadata: { component: { name: "@example/widget" } } })); + expect(r.code).toBe(1); + expect(r.stderr).toContain("version:"); + }); + + it("[AC-6] refuses an inventory carrying a version the manifest no longer publishes", () => { + // The stale case the release path has to catch: an inventory produced before the version + // bump describes the previous release and would be attached to this one. + const r = verifyOnly( + soundArtifact({ metadata: { component: { name: "@example/widget", version: "1.2.2" } } }), + ); + expect(r.code).toBe(1); + expect(r.stderr).toContain("version:"); + expect(r.stderr).toContain("1.2.3"); + }); + + it("[AC-6] refuses an inventory that omits a dependency the build resolved, and names it", () => { + const short = JSON.parse(soundArtifact()) as { components: unknown[] }; + short.components = short.components.slice(0, 1); + const r = verifyOnly(JSON.stringify(short, null, 2)); + expect(r.code).toBe(1); + expect(r.stderr).toContain("incomplete:"); + expect(r.stderr).toContain("transitive-only@1.9.12"); + }); + + it("[AC-6] refuses an inventory carrying a version other than the one resolved", () => { + const drifted = JSON.parse(soundArtifact()) as { components: { version: string }[] }; + const entry = drifted.components[0]; + if (entry) entry.version = "0.4.2"; + const r = verifyOnly(JSON.stringify(drifted, null, 2)); + expect(r.code).toBe(1); + expect(r.stderr).toContain("incomplete:"); + expect(r.stderr).toContain("@example/scoped-dep@0.4.1"); + }); + + it("[AC-6] names every condition that failed, not only the first", () => { + const r = verifyOnly( + soundArtifact({ + components: [], + metadata: { component: { name: "@example/other", version: "0.0.1" } }, + }), + ); + expect(r.code).toBe(1); + expect(r.stderr).toContain("empty:"); + expect(r.stderr).toContain("identity:"); + expect(r.stderr).toContain("version:"); + }); + + it("[AC-6] accepts an inventory that is true of the build, so the refusals above are not vacuous", () => { + const r = verifyOnly(soundArtifact()); + expect(r.code, `stderr: ${r.stderr}`).toBe(0); + expect(r.stdout).toContain("OK"); + }); +}); From 37d9f38baa6abd0ee1a6e5ec2b3d8adf22f52ee3 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Tue, 22 Sep 2026 14:23:11 +0000 Subject: [PATCH 2/2] S0349-astm-10: remove the backtracking ambiguity from the version pattern CodeQL js/redos on scripts/check-dependency-inventory.ts. The repeated group opened with [-+] while its own character class also carried -, so a near miss such as 0.0.0+ followed by repetitions of -- backtracked exponentially: 1.2ms to reject at 27 characters, 9.0ms at 35, 99ms at 39, 704ms at 43, and a 59-character input did not finish in three minutes. That is reachable rather than theoretical. The text comes from a lockfile key, which is to say from a registry, and this script runs in the publishing path. Each tail is now optional rather than repeated, and the prerelease class can no longer swallow the +, so the split is decided by the first + and there is nothing to backtrack over. Flat at 0.017ms on a 2007-character input, and it accepts and rejects identically on every input that matters. No test added: the subject is performance rather than a criterion, and CodeQL already holds it. --- scripts/check-dependency-inventory.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/check-dependency-inventory.ts b/scripts/check-dependency-inventory.ts index 823ff02..de1dee8 100644 --- a/scripts/check-dependency-inventory.ts +++ b/scripts/check-dependency-inventory.ts @@ -169,8 +169,20 @@ function readManifest(path: string): Manifest { /** A `packages:` key, in each of the three ways this lockfile format writes one. */ const PACKAGE_KEY = /^ {2}(?:'([^']+)'|"([^"]+)"|([^\s#'"][^:]*)):\s*$/; -/** A resolved version, as the `packages:` section spells one. */ -const RESOLVED_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/; +/** + * A resolved version, as the `packages:` section spells one: a semver core with an optional + * prerelease and an optional build tail. + * + * THE TWO TAILS ARE SEPARATE AND EACH IS OPTIONAL RATHER THAN REPEATED, deliberately. The + * ambiguous form `(?:[-+][0-9A-Za-z.-]+)*` accepts the same strings and backtracks + * exponentially on a near miss, because its leading `[-+]` overlaps the `-` inside its own + * character class: `0.0.0+` followed by 18 repetitions of `--` took 704ms to reject and each + * further four characters multiplied that by about seven. The version text comes from a lockfile + * key, which is to say from a registry, and this runs in the publishing path, so that is reachable + * rather than theoretical. Written this way the prerelease class cannot swallow the `+`, so the + * split is decided by the first `+` and there is nothing to backtrack over. + */ +const RESOLVED_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; /** * Split a `packages:` key into a name and the version resolved for it.