From da2ea580f6735aa4f3932f11d9e617a56b687bad Mon Sep 17 00:00:00 2001 From: t Date: Tue, 15 Sep 2026 15:39:10 +0530 Subject: [PATCH 1/2] Automate the frontend third-party licence inventory THIRD_PARTY_LICENSES.txt was the last hand-maintained link in the dependency chain. The Rust inventory has had generate-rust-licenses.mjs for some time; the frontend equivalent did not exist, so every frontend bump required a human to hand-edit version headers before check-dependency-inventory.mjs --frontend would pass. That tax is what issues #349, #350 and #351 describe, and it was paid by hand twice today. scripts/generate-frontend-licenses.mjs reads pnpm licenses list --prod --json, resolves each package's own LICENSE file from its install path, groups packages sharing byte-identical licence text, and writes the file in the existing format. It is wired as license:generate:frontend, mirroring the Rust wiring, plus a license:generate that runs both. Regenerating revealed two defects in the hand-maintained file: - lucide-react's header underline was 20 dashes under a 19-character header, left over from an earlier version bump. Precisely the class of drift this replaces. - The react/react-dom/scheduler group cited facebook/react. react and react-dom now both declare react/react in their published package.json; only scheduler still says facebook. The generator takes the first-alphabetical member's URL and warns at generation time when a group disagrees, rather than silently choosing. Determinism proven by running twice and comparing md5. A simulated lucide-react bump regenerated and passed license:all with no hand-editing. Corrupting the generated file still fails check-dependency-inventory with a precise drift message, so the checker is not made vacuous. package.json is pinned; the compatibility surface was resealed in the documented order and a confirming rehash reports zero changes. Co-Authored-By: Claude Opus 5 --- THIRD_PARTY_LICENSES.txt | 4 +- docs/proposed-license-automation.md | 78 +++++ .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- package.json | 4 +- scripts/generate-frontend-licenses.mjs | 267 ++++++++++++++++++ scripts/generate-frontend-licenses.test.mjs | 141 +++++++++ 7 files changed, 494 insertions(+), 6 deletions(-) create mode 100644 docs/proposed-license-automation.md create mode 100644 scripts/generate-frontend-licenses.mjs create mode 100644 scripts/generate-frontend-licenses.test.mjs diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt index 3842bf53..66c3cd9c 100644 --- a/THIRD_PARTY_LICENSES.txt +++ b/THIRD_PARTY_LICENSES.txt @@ -33,7 +33,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. lucide-react 1.41.0 --------------------- +------------------- License: ISC Source: https://github.com/lucide-icons/lucide @@ -84,7 +84,7 @@ SOFTWARE. react 19.2.8, react-dom 19.2.8, scheduler 0.27.0 ------------------------------------------------ License: MIT -Source: https://github.com/facebook/react +Source: https://github.com/react/react MIT License diff --git a/docs/proposed-license-automation.md b/docs/proposed-license-automation.md new file mode 100644 index 00000000..b765e41e --- /dev/null +++ b/docs/proposed-license-automation.md @@ -0,0 +1,78 @@ +# Proposed CI wiring for `scripts/generate-frontend-licenses.mjs` + +This repo's `.github/` workflows are intentionally untouched by the change that +added `scripts/generate-frontend-licenses.mjs`. This document is the proposal +for wiring it in, for a maintainer to review and apply. + +## What already works without any CI change + +`ci.yml`'s `frontend` job already runs `corepack pnpm run license:check` +(`check-license-metadata.mjs` + `check-dependency-inventory.mjs --frontend`), +and `license:all` runs in the release workflow. Both keep failing a Dependabot +PR exactly as before until `THIRD_PARTY_LICENSES.txt` is regenerated — the +generator does not change what CI enforces, only how a human (or a bot) +satisfies it: `corepack pnpm run license:generate:frontend` now does in one +command what used to be a hand-edit. + +## Gap 1 (pre-existing, not introduced by this change): the `bundle` path filter + +`ci.yml` line 66 decides whether the bundle/license jobs run by grepping +`changed_files` against an explicit allowlist. That allowlist already omits +`scripts/generate-rust-licenses.mjs` — a change to the Rust generator alone +does not mark `bundle=true` — and the same gap now applies to +`scripts/generate-frontend-licenses.mjs`. In practice this is low-risk (the +generators are touched rarely and almost always alongside `package.json` / +`pnpm-lock.yaml`, which are already in the allowlist), but it is a real gap. +Proposed fix, folded into the existing regex on line 66: + +```diff +- scripts/(capture-package-log(\.test)?\.py|check-mcpb-bundle(\.test)?\.py|package-mcpb\.mjs|check-license-metadata\.mjs|check-dependency-inventory\.mjs|check-windows-bundle-resources\.ps1|check-macos-bundle-resources(\.mutation)?\.mjs)$ ++ scripts/(capture-package-log(\.test)?\.py|check-mcpb-bundle(\.test)?\.py|package-mcpb\.mjs|check-license-metadata\.mjs|check-dependency-inventory\.mjs|generate-rust-licenses\.mjs|generate-frontend-licenses(\.test)?\.mjs|check-windows-bundle-resources\.ps1|check-macos-bundle-resources(\.mutation)?\.mjs)$ +``` + +## Gap 2 (the actual manual-regeneration tax): nothing regenerates the file for you + +Today a Dependabot PR that bumps a frontend dependency still needs a human to +run the generator and push a commit before `license:check` goes green. The +generator makes that a one-line, no-judgment-calls command, so it is a good +candidate for a bot step. Proposed addition to `ci.yml`'s `frontend` job (or a +separate `dependabot`-triggered job — either works; shown here as a step +appended to the existing job, gated so it only ever runs for Dependabot's own +branches and never mutates a human-authored PR silently): + +```yaml + - run: corepack pnpm install --frozen-lockfile + - run: corepack pnpm run license:generate:frontend + - name: Fail if the frontend license inventory needed regeneration + if: github.actor != 'dependabot[bot]' + run: git diff --exit-code -- THIRD_PARTY_LICENSES.txt + - name: Commit regenerated frontend license inventory + if: github.actor == 'dependabot[bot]' && !cancelled() + run: | + if ! git diff --quiet -- THIRD_PARTY_LICENSES.txt; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add THIRD_PARTY_LICENSES.txt + git commit -m "chore(licenses): regenerate frontend inventory" + git push "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" "HEAD:${{ github.head_ref }}" + fi + - run: corepack pnpm run license:check +``` + +Notes for whoever applies this: + +- Dependabot PRs from forks (n/a here, since this repo's Dependabot runs + against the base repo directly) would need `pull_request_target` plus the + usual secret-exposure caution; verify this repo's Dependabot config before + copying the push step as-is. +- The default `GITHUB_TOKEN` needs `contents: write` on that job, and pushing + to a PR branch from an Actions run must not be blocked by branch protection + on Dependabot's branch naming pattern (`dependabot/npm_and_yarn/*`). +- For a non-Dependabot PR, the added `git diff --exit-code` step turns a stale + inventory into a clear, fast CI failure ("run `pnpm run + license:generate:frontend` and commit the result") instead of the opaque + `check-dependency-inventory.mjs` drift error a contributor currently has to + interpret and fix by hand. +- The same shape (regenerate, diff-or-commit, then re-check) applies to + `license:generate:rust` / `THIRD_PARTY_LICENSES_RUST.txt`, which already + exists but has never been wired into CI this way either. diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 0b402bc5..5234f2fe 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "a2e72e983a53ed8a2d99b9a4b8dda2d74794e3dd44d89987076bb32299421246", + "compatibility_surface_sha256": "2753cd747812ae23aff7e75191cfbaf3e4ce615ac52f4606e683961c1b9ba7d4", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index c936803c..560851eb 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -75,7 +75,7 @@ }, { "path": "package.json", - "sha256": "176159c99c866130d8ec82a3fef5dce43185ba749fdef762a6c4d49c401a4578" + "sha256": "a709faf2b21d05facf6c33bd1e4ff9a158cf37907eadf0d7a8eb4563d8c12e50" }, { "path": "pnpm-lock.yaml", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "a2e72e983a53ed8a2d99b9a4b8dda2d74794e3dd44d89987076bb32299421246" + "manifest_sha256": "2753cd747812ae23aff7e75191cfbaf3e4ce615ac52f4606e683961c1b9ba7d4" } \ No newline at end of file diff --git a/package.json b/package.json index 436a0fcc..643c1b6b 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,9 @@ "license:check": "node scripts/check-license-metadata.mjs && node scripts/check-dependency-inventory.mjs --frontend", "license:rust": "node scripts/check-dependency-inventory.mjs --rust", "license:all": "node scripts/check-license-metadata.mjs && node scripts/check-dependency-inventory.mjs", - "license:generate:rust": "node scripts/generate-rust-licenses.mjs" + "license:generate:rust": "node scripts/generate-rust-licenses.mjs", + "license:generate:frontend": "node scripts/generate-frontend-licenses.mjs", + "license:generate": "node scripts/generate-rust-licenses.mjs && node scripts/generate-frontend-licenses.mjs" }, "dependencies": { "@tauri-apps/api": "^2.11.0", diff --git a/scripts/generate-frontend-licenses.mjs b/scripts/generate-frontend-licenses.mjs new file mode 100644 index 00000000..2b3aba98 --- /dev/null +++ b/scripts/generate-frontend-licenses.mjs @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Regenerates THIRD_PARTY_LICENSES.txt from the resolved production frontend +// dependency graph (pnpm-lock.yaml / package.json), the same source of truth +// `scripts/check-dependency-inventory.mjs --frontend` validates against. +// +// The license text of an "A OR B" package is chosen from PREFERRED_LICENSE_IDS, +// but the printed `License:` line always keeps pnpm's full SPDX expression, so +// the checker (which only greps for `name version` tokens) and this generator +// can never disagree on what is "in" the inventory -- only on which license +// text is quoted for a dual-licensed package. + +import { readFile, readdir, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const reportUrl = new URL("../THIRD_PARTY_LICENSES.txt", import.meta.url); + +const HEADER = `Bridge frontend third-party licenses +==================================== + +The native Rust dependency inventory and full license texts are provided in +THIRD_PARTY_LICENSES_RUST.txt. This file covers the production frontend +dependency graph from pnpm-lock.yaml. +`; + +// Preference order used only to pick which license TEXT to quote when a +// package publishes an "A OR B" expression (e.g. "Apache-2.0 OR MIT"). The +// reported `License:` field always keeps the full expression regardless. +const PREFERRED_LICENSE_IDS = [ + "MIT", + "ISC", + "0BSD", + "BSD-2-Clause", + "BSD-3-Clause", + "Apache-2.0", + "CC0-1.0", + "Unlicense", +]; + +// Extensions that mark a license file as the package's single, generic +// license text rather than one specific alternative of an "A OR B" choice. +const GENERIC_SUFFIXES = new Set(["", "MD", "TXT", "RST"]); + +const idKey = (value) => value.toUpperCase().replace(/[^A-Z0-9]/g, ""); + +const runJson = (command, args, label) => { + const result = spawnSync(command, args, { + cwd: root, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + windowsHide: true, + }); + if (result.error || result.status !== 0) { + throw new Error(`${label} command failed`); + } + try { + return JSON.parse(result.stdout); + } catch { + throw new Error(`${label} command returned invalid JSON`); + } +}; + +// Same normalization the Rust generator applies: strip trailing +// per-line whitespace and outer blank lines, so a stray space or CRLF +// in an upstream LICENSE file can never cause spurious inventory drift. +export function normalizeLicenseText(text) { + return text.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim(); +} + +// Reduces a package.json `repository` field (string, object, git+/git:/// +// scp-style URL, or a bare "owner/repo") plus a `homepage` fallback to the +// plain https URL this file has always printed on its `Source:` line. +export function normalizeRepositoryUrl({ repository, homepage } = {}) { + let raw = typeof repository === "string" ? repository : repository?.url; + if (raw) { + if (raw.startsWith("github:")) raw = `https://github.com/${raw.slice("github:".length)}`; + raw = raw.replace(/^git\+/, ""); + const scp = raw.match(/^git@([^:]+):(.+)$/); + if (scp) raw = `https://${scp[1]}/${scp[2]}`; + raw = raw.replace(/^git:\/\//, "https://"); + raw = raw.replace(/^ssh:\/\/git@/, "https://"); + raw = raw.replace(/\.git$/, ""); + return raw; + } + if (homepage) { + return homepage.replace(/#readme$/, "").replace(/\/+$/, ""); + } + return null; +} + +// Finds every LICENSE*/COPYING* file at the top of a package directory and +// classifies each as either the package's one generic license text, or the +// text for one specific alternative of an "A OR B" SPDX expression. +async function findLicenseFiles(packageDir) { + let entries; + try { + entries = await readdir(packageDir, { withFileTypes: true }); + } catch (cause) { + throw new Error(`could not read package directory ${packageDir}: ${cause.message}`); + } + const generic = []; + const byId = new Map(); + for (const entry of entries) { + if (!entry.isFile()) continue; + const match = entry.name.match(/^(licen[cs]e|copying)([._-](.+))?$/i); + if (!match) continue; + const suffix = idKey(match[3] ?? ""); + if (GENERIC_SUFFIXES.has(suffix)) { + generic.push(entry.name); + } else { + byId.set(suffix, entry.name); + } + } + generic.sort(); + return { genericFile: generic[0] ?? null, byId }; +} + +// Resolves the (id printed on Source's companion License: line is always the +// full pnpm expression -- this only decides which file's TEXT gets quoted) +// license text for one resolved package. +async function resolveLicenseText(name, version, licenseExpression, packageDir) { + const { genericFile, byId } = await findLicenseFiles(packageDir); + const alternatives = licenseExpression.includes(" AND ") + ? null + : licenseExpression.replace(/^\(|\)$/g, "").split(" OR ").map((part) => part.trim()); + if (!alternatives) { + throw new Error( + `${name} ${version}: compound license expression "${licenseExpression}" (AND) is not ` + + "handled automatically; resolve its license text manually and extend the generator.", + ); + } + + let chosenFile = null; + if (alternatives.length === 1) { + chosenFile = byId.get(idKey(alternatives[0])) ?? genericFile; + } else { + for (const preferred of PREFERRED_LICENSE_IDS) { + if (!alternatives.some((alt) => idKey(alt) === idKey(preferred))) continue; + const file = byId.get(idKey(preferred)); + if (file) { + chosenFile = file; + break; + } + } + if (!chosenFile && genericFile) { + console.warn( + `${name} ${version}: "${licenseExpression}" has no per-license file for any of ` + + `${PREFERRED_LICENSE_IDS.join(", ")}; using its single ${genericFile} for all alternatives.`, + ); + chosenFile = genericFile; + } + } + + if (!chosenFile) { + throw new Error( + `${name} ${version}: could not find a license file for "${licenseExpression}" in ` + + `${packageDir}. Add a documented override or a matching LICENSE_ file upstream.`, + ); + } + const text = await readFile(join(packageDir, chosenFile), "utf8"); + return normalizeLicenseText(text); +} + +// Groups resolved packages that quote byte-identical license text (e.g. +// react/react-dom/scheduler) into the single inventory entry the existing +// file format expects, and orders entries/members alphabetically by name -- +// the order the committed file has always used. +export function buildGroups(resolved) { + const byText = new Map(); + for (const pkg of resolved) { + const key = pkg.text; + if (!byText.has(key)) byText.set(key, []); + byText.get(key).push(pkg); + } + const groups = [...byText.values()].map((members) => { + members.sort((a, b) => a.name.localeCompare(b.name, "en")); + const licenses = new Set(members.map((m) => m.license)); + if (licenses.size > 1) { + throw new Error( + `packages sharing identical license text disagree on their SPDX expression: ` + + `${members.map((m) => `${m.name}@${m.license}`).join(", ")}`, + ); + } + const sources = new Set(members.map((m) => m.source).filter(Boolean)); + if (sources.size > 1) { + console.warn( + `${members.map((m) => m.name).join(", ")} share license text but report different ` + + `repository URLs (${[...sources].join(" vs ")}); using ${members[0].name}'s: ` + + `${members[0].source}.`, + ); + } + if (!members[0].source) { + throw new Error(`${members[0].name} ${members[0].version}: no repository or homepage to source from`); + } + return { + members, + license: members[0].license, + source: members[0].source, + text: members[0].text, + }; + }); + groups.sort((a, b) => a.members[0].name.localeCompare(b.members[0].name, "en")); + return groups; +} + +export function renderInventory(groups) { + const entries = groups.map((group) => { + const header = group.members.map((m) => `${m.name} ${m.version}`).join(", "); + const underline = "-".repeat(header.length); + return `${header}\n${underline}\nLicense: ${group.license}\nSource: ${group.source}\n\n${group.text}`; + }); + return `${HEADER}\n${entries.join("\n\n")}\n`; +} + +async function resolvePackageJson(packageDir) { + const raw = await readFile(join(packageDir, "package.json"), "utf8"); + return JSON.parse(raw); +} + +async function collectResolvedPackages() { + const packageManager = process.env.npm_execpath; + if (!packageManager) { + throw new Error("Run the frontend license generator through the pinned pnpm script"); + } + const licenses = runJson( + process.execPath, + [packageManager, "licenses", "list", "--prod", "--json"], + "frontend license inventory", + ); + + const resolved = []; + for (const [licenseExpression, packages] of Object.entries(licenses)) { + for (const dependency of packages) { + for (let i = 0; i < dependency.versions.length; i += 1) { + const version = dependency.versions[i]; + const packageDir = dependency.paths[i] ?? dependency.paths[0]; + const pkgJson = await resolvePackageJson(packageDir); + const text = await resolveLicenseText(dependency.name, version, licenseExpression, packageDir); + const source = normalizeRepositoryUrl(pkgJson); + resolved.push({ + name: dependency.name, + version, + license: licenseExpression, + source, + text, + }); + } + } + } + return resolved; +} + +async function main() { + const resolved = await collectResolvedPackages(); + const groups = buildGroups(resolved); + const inventory = renderInventory(groups); + await writeFile(reportUrl, inventory, "utf8"); + console.log( + `Regenerated THIRD_PARTY_LICENSES.txt for ${resolved.length} production frontend ` + + `dependencies (${groups.length} license entries).`, + ); +} + +if (import.meta.main) await main(); diff --git a/scripts/generate-frontend-licenses.test.mjs b/scripts/generate-frontend-licenses.test.mjs new file mode 100644 index 00000000..d1d40ef5 --- /dev/null +++ b/scripts/generate-frontend-licenses.test.mjs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + buildGroups, + normalizeLicenseText, + normalizeRepositoryUrl, + renderInventory, +} from "./generate-frontend-licenses.mjs"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const reportPath = fileURLToPath(new URL("../THIRD_PARTY_LICENSES.txt", import.meta.url)); + +test("repository URL normalization matches every form package.json ships", () => { + assert.equal( + normalizeRepositoryUrl({ repository: { url: "git+https://github.com/tauri-apps/tauri.git" } }), + "https://github.com/tauri-apps/tauri", + ); + assert.equal( + normalizeRepositoryUrl({ repository: { url: "https://github.com/lucide-icons/lucide.git", directory: "packages/lucide-react" } }), + "https://github.com/lucide-icons/lucide", + ); + assert.equal( + normalizeRepositoryUrl({ repository: "git://github.com/owner/repo.git" }), + "https://github.com/owner/repo", + ); + assert.equal( + normalizeRepositoryUrl({ repository: "git@github.com:owner/repo.git" }), + "https://github.com/owner/repo", + ); + assert.equal( + normalizeRepositoryUrl({ repository: "github:owner/repo" }), + "https://github.com/owner/repo", + ); + assert.equal( + normalizeRepositoryUrl({ repository: { url: "git+ssh://git@github.com/owner/repo.git" } }), + "https://github.com/owner/repo", + ); + assert.equal( + normalizeRepositoryUrl({ homepage: "https://example.dev/pkg#readme" }), + "https://example.dev/pkg", + ); + assert.equal(normalizeRepositoryUrl({}), null); +}); + +test("license text normalization strips trailing whitespace and CRLF without touching internal blank lines", () => { + const raw = "MIT License\r\n\r\nCopyright (c) X \r\n\r\nSome body. \n\ntrailing blank\n\n\n"; + assert.equal( + normalizeLicenseText(raw), + "MIT License\n\nCopyright (c) X\n\nSome body.\n\ntrailing blank", + ); +}); + +test("buildGroups merges byte-identical license text and keeps the shared SPDX expression", () => { + const groups = buildGroups([ + { name: "react", version: "19.2.8", license: "MIT", source: "https://github.com/react/react", text: "SHARED" }, + { name: "scheduler", version: "0.27.0", license: "MIT", source: "https://github.com/facebook/react", text: "SHARED" }, + { name: "react-dom", version: "19.2.8", license: "MIT", source: "https://github.com/react/react", text: "SHARED" }, + ]); + assert.equal(groups.length, 1); + assert.deepEqual(groups[0].members.map((m) => m.name), ["react", "react-dom", "scheduler"]); + assert.equal(groups[0].license, "MIT"); + // First member alphabetically wins when a merged group's sources disagree. + assert.equal(groups[0].source, "https://github.com/react/react"); +}); + +test("buildGroups orders distinct entries alphabetically by their first member's name", () => { + const groups = buildGroups([ + { name: "zeta", version: "1.0.0", license: "MIT", source: "https://example.com/zeta", text: "Z" }, + { name: "@scope/alpha", version: "1.0.0", license: "MIT", source: "https://example.com/alpha", text: "A" }, + ]); + assert.deepEqual(groups.map((g) => g.members[0].name), ["@scope/alpha", "zeta"]); +}); + +test("buildGroups refuses to merge identical text under disagreeing SPDX expressions", () => { + assert.throws( + () => buildGroups([ + { name: "a", version: "1.0.0", license: "MIT", source: "https://example.com/a", text: "SAME" }, + { name: "b", version: "1.0.0", license: "ISC", source: "https://example.com/b", text: "SAME" }, + ]), + /disagree on their SPDX expression/, + ); +}); + +test("buildGroups requires a resolvable source", () => { + assert.throws( + () => buildGroups([{ name: "a", version: "1.0.0", license: "MIT", source: null, text: "TEXT" }]), + /no repository or homepage/, + ); +}); + +test("renderInventory reproduces the header/underline/license-block format the checker expects", () => { + const groups = buildGroups([ + { name: "only-pkg", version: "2.0.0", license: "0BSD", source: "https://example.com/only", text: "License body." }, + ]); + const rendered = renderInventory(groups); + assert.equal( + rendered, + "Bridge frontend third-party licenses\n" + + "====================================\n\n" + + "The native Rust dependency inventory and full license texts are provided in\n" + + "THIRD_PARTY_LICENSES_RUST.txt. This file covers the production frontend\n" + + "dependency graph from pnpm-lock.yaml.\n\n" + + "only-pkg 2.0.0\n" + + "--------------\n" + + "License: 0BSD\n" + + "Source: https://example.com/only\n\n" + + "License body.\n", + ); +}); + +test("the committed file's `name version` tokens are exactly what the generator (re-run) produces", async (t) => { + // Exercises the real generator end to end against the actual lockfile/node_modules, + // then restores whatever THIRD_PARTY_LICENSES.txt looked like before the test ran -- + // this test intentionally writes the real file, so it must always leave it as found. + const before = await readFile(reportPath, "utf8"); + t.after(async () => writeFile(reportPath, before, "utf8")); + + // `check-dependency-inventory.mjs --frontend` (and, by convention, this + // generator) refuses to run unless npm_execpath is set, which only happens + // when a pnpm script invokes it -- so drive both through `pnpm run`, + // exactly as `pnpm run license:generate:frontend` / `pnpm run license:check` + // do for a real user. + const runScript = (script) => execFileSync("corepack", ["pnpm", "run", script], { + cwd: root, + encoding: "utf8", + }); + + runScript("license:generate:frontend"); + const first = await readFile(reportPath, "utf8"); + runScript("license:generate:frontend"); + const second = await readFile(reportPath, "utf8"); + assert.equal(first, second, "generator output must be deterministic across consecutive runs"); + + runScript("license:check"); +}); From a14ea83dc74e05c29a303c9235d9a9245e139267 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 15 Sep 2026 16:44:53 +0530 Subject: [PATCH 2/2] Regenerate the frontend licence inventory after the master merge The previous commit on this branch pushed unresolved conflict markers in THIRD_PARTY_LICENSES.txt: the merge left that file conflicted alongside the two compatibility manifests, and only the manifests were resolved before staging. Regenerating with the generator this branch adds resolves it outright -- which is the point of the change. license:all now passes on 5 frontend and 384 Rust locked components, and two consecutive generator runs produce a byte-identical file. Co-Authored-By: Claude Opus 5 --- THIRD_PARTY_LICENSES.txt | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt index 5bbd30a7..1566f664 100644 --- a/THIRD_PARTY_LICENSES.txt +++ b/THIRD_PARTY_LICENSES.txt @@ -32,16 +32,8 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -<<<<<<< HEAD -lucide-react 1.41.0 -------------------- -||||||| 0f11118d -lucide-react 1.41.0 --------------------- -======= lucide-react 1.46.0 --------------------- ->>>>>>> origin/master +------------------- License: ISC Source: https://github.com/lucide-icons/lucide