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..de1dee8 --- /dev/null +++ b/scripts/check-dependency-inventory.ts @@ -0,0 +1,503 @@ +#!/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: 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. + * + * 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"); + }); +});