diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d20d580b..ac38ec62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,6 +185,13 @@ jobs: node-version-file: '.nvmrc' cache: 'npm' + # TIN-161. The vitest suite covers this too, but it runs in a copied + # sandbox with no .git, so the build-metrics provenance check is only + # reachable here — at a real checkout with history. Dependency-free by + # design: no install step required. + - name: Truth surfaces + run: node scripts/truth-surfaces.mjs + - run: npm ci - name: Static production dependency audit diff --git a/BUILD.bazel b/BUILD.bazel index 9992fed9..c0e1a9ea 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -21,6 +21,11 @@ filegroup( allow_empty = True, ) + [ ".npmrc", + # Truth surfaces checked by scripts/truth-surfaces.test.mts (TIN-161): + # the test compares these documents against package-lock.json, so they + # have to reach the Vitest sandbox alongside it. + "THIRD-PARTY-LICENSES.md", + "docs/build-metrics.md", "package-lock.json", "package.json", "pnpm-lock.yaml", diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md index 38454497..4f1a1ce7 100644 --- a/THIRD-PARTY-LICENSES.md +++ b/THIRD-PARTY-LICENSES.md @@ -1,55 +1,153 @@ # Third-Party Licenses -This project (transscendsurvival.org) is dedicated to the public domain -under [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/). + -The site is built with open-source software. Below is an attribution of -all direct dependencies and their licenses. +This project (transscendsurvival.org) is dedicated to the public domain under +[CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/). + +Every figure below is derived from `package-lock.json` — the package name and +its declared range from `package.json`, the resolved version, license string +and source URL from the lockfile entry for that exact resolution. Nothing here +is hand-entered, so nothing here can drift without `scripts/truth-surfaces.mjs` +failing. + +Regenerate with: + +```sh +node scripts/truth-surfaces.mjs --write +``` + +That command writes this file and `src/lib/data/third-party-licenses.json`, which +`src/routes/THIRD-PARTY-LICENSES/+page.svelte` renders — the published page and +this document are the same data, so they cannot disagree. + +A dependency whose lockfile entry declares no license is printed as +`UNDECLARED`. It is never guessed from the package name, the organisation, or +a sibling version. + +The previous revision of this file was hand-maintained and had drifted: it +listed `shiki 3.22.0`, `vite 6.4.1`, `svelte 5.50.1` and `sharp 0.33.5` +against resolutions of 4.2.0, 8.0.14, 5.55.4 and 0.34.5, carried `mermaid` as +a runtime dependency it no longer is, and omitted most of the direct set. ## Runtime Dependencies -| Package | Version | License | Repository | -|---------|---------|---------|------------| -| @skeletonlabs/skeleton | 5.0.0 | MIT | https://github.com/skeletonlabs/skeleton | -| @skeletonlabs/skeleton-svelte | 5.0.0 | MIT | https://github.com/skeletonlabs/skeleton | -| mdsvex | 0.12.6 | MIT | https://github.com/pngwn/MDsveX | -| mermaid | 11.12.2 | MIT | https://github.com/mermaid-js/mermaid | -| shiki | 3.22.0 | MIT | https://github.com/shikijs/shiki | -| tailwindcss | 4.1.18 | MIT | https://github.com/tailwindlabs/tailwindcss | +| Package | Declared | Resolved | License | Source | +| --- | --- | --- | --- | --- | +| @fontsource/crimson-pro | `^5.2.8` | 5.2.8 | OFL-1.1 | | +| @fontsource/fira-code | `^5.2.7` | 5.2.7 | OFL-1.1 | | +| @fontsource/inter | `^5.2.8` | 5.2.8 | OFL-1.1 | | +| @fontsource/raleway | `^5.2.8` | 5.2.8 | OFL-1.1 | | +| @skeletonlabs/skeleton | `5.0.0` | 5.0.0 | MIT | | +| @skeletonlabs/skeleton-svelte | `5.0.0` | 5.0.0 | MIT | | +| @tummycrypt/tinyvectors | `https://github.com/tinyland-inc/tinyvectors/archive/refs/tags/v0.3.5.tar.gz` | 0.3.5 | Zlib | | +| dompurify | `^3.4.13` | 3.4.13 | (MPL-2.0 OR Apache-2.0) | | +| flexsearch | `^0.8.212` | 0.8.212 | Apache-2.0 | | +| marked | `^4.3.0` | 4.3.0 | MIT | | +| mdsvex | `^0.12.7` | 0.12.7 | MIT | | +| shiki | `^4.2.0` | 4.2.0 | MIT | | +| tailwindcss | `^4.2.2` | 4.2.2 | MIT | | ## Build / Dev Dependencies -| Package | Version | License | Repository | -|---------|---------|---------|------------| -| @sveltejs/adapter-static | 3.0.10 | MIT | https://github.com/sveltejs/kit | -| @sveltejs/enhanced-img | 0.10.0 | MIT | https://github.com/sveltejs/kit | -| @sveltejs/kit | 2.50.2 | MIT | https://github.com/sveltejs/kit | -| @sveltejs/vite-plugin-svelte | 6.2.4 | MIT | https://github.com/sveltejs/vite-plugin-svelte | -| @tailwindcss/vite | 4.1.18 | MIT | https://github.com/tailwindlabs/tailwindcss | -| pagefind | 1.4.0 | MIT | https://github.com/CloudCannon/pagefind | -| sharp | 0.33.5 | Apache-2.0 | https://github.com/lovell/sharp | -| svelte | 5.50.1 | MIT | https://github.com/sveltejs/svelte | -| svelte-check | 4.3.6 | MIT | https://github.com/sveltejs/language-tools | -| typescript | 5.9.3 | Apache-2.0 | https://github.com/microsoft/TypeScript | -| vite | 6.4.1 | MIT | https://github.com/vitejs/vite | - -## Notable Transitive Dependencies - -| Package | License | Notes | -|---------|---------|-------| -| chevrotain | Apache-2.0 | Parser toolkit (mermaid dependency) | -| d3 | ISC / BSD-3-Clause | Visualization library (mermaid dependency) | -| dompurify | MPL-2.0 OR Apache-2.0 | HTML sanitizer (mermaid dependency) | -| @iconify/utils | MIT | Icon utilities (skeleton dependency) | -| @floating-ui/dom | MIT | Positioning runtime (Skeleton/Zag dependency) | - -## License Summary - -All 243 production packages break down as: - -- **MIT**: 190 -- **ISC**: 34 -- **Apache-2.0**: 8 -- **BSD-3-Clause**: 6 -- **MPL-2.0 OR Apache-2.0**: 1 -- **Unlicense**: 1 +| Package | Declared | Resolved | License | Source | +| --- | --- | --- | --- | --- | +| @eslint/js | `^9.39.2` | 9.39.4 | MIT | | +| @mermaid-js/mermaid-cli | `^11.15.0` | 11.15.0 | MIT | | +| @playwright/test | `^1.59.1` | 1.59.1 | Apache-2.0 | | +| @sveltejs/adapter-node | `^5.5.7` | 5.5.7 | MIT | | +| @sveltejs/adapter-static | `^3.0.10` | 3.0.10 | MIT | | +| @sveltejs/kit | `^2.61.1` | 2.61.1 | MIT | | +| @sveltejs/vite-plugin-svelte | `^7.1.2` | 7.1.2 | MIT | | +| @tailwindcss/vite | `^4.2.2` | 4.2.2 | MIT | | +| @tummycrypt/vite-plugin-a11y | `^0.2.2` | 0.2.2 | Zlib AND LicenseRef-Tinyland-Proprietary | | +| @types/node | `^25.2.3` | 25.5.0 | MIT | | +| @vitest/coverage-v8 | `^4.1.4` | 4.1.4 | MIT | | +| eslint | `^9.39.2` | 9.39.4 | MIT | | +| eslint-config-prettier | `^10.1.8` | 10.1.8 | MIT | | +| eslint-plugin-svelte | `^3.17.0` | 3.17.0 | MIT | | +| pagefind | `^1.5.2` | 1.5.2 | MIT | | +| prettier | `^3.8.1` | 3.8.1 | MIT | | +| prettier-plugin-svelte | `^3.4.1` | 3.5.1 | MIT | | +| puppeteer | `^24.43.1` | 24.43.1 | Apache-2.0 | | +| rehype-slug | `^6.0.0` | 6.0.0 | MIT | | +| rollup-plugin-visualizer | `^6.0.5` | 6.0.11 | MIT | | +| serve | `^14.2.5` | 14.2.6 | MIT | | +| sharp | `^0.34.5` | 0.34.5 | Apache-2.0 | | +| svelte | `^5.55.4` | 5.55.4 | MIT | | +| svelte-check | `^4.4.5` | 4.4.5 | MIT | | +| tsx | `^4.21.0` | 4.21.0 | MIT | | +| typescript | `^5.9.3` | 5.9.3 | Apache-2.0 | | +| typescript-eslint | `^8.58.2` | 8.58.2 | MIT | | +| vite | `^8.0.14` | 8.0.14 | MIT | | +| vitest | `^4.1.4` | 4.1.4 | MIT | | + +## Resolved Tree + +`package-lock.json` records 1150 installs under `node_modules/`. +That is a count of install paths, not of packages: npm records a package once +per place it lands, so a dependency it cannot hoist appears many times over. +Deduplicated on `name@version`, the tree is **1078** distinct +third-party packages, of which **361** are reachable +outside `devDependencies`. + +### Runtime Packages + +This is the set that governs attribution: the site is a prerendered static +build, so only these licenses travel to a reader. + +| License | Packages | +| --- | --- | +| MIT | 225 | +| Apache-2.0 | 106 | +| BSD-3-Clause | 11 | +| ISC | 10 | +| OFL-1.1 | 4 | +| (MPL-2.0 OR Apache-2.0) | 1 | +| 0BSD | 1 | +| BSD-2-Clause | 1 | +| UNDECLARED | 1 | +| Zlib | 1 | + +### Full Resolved Tree + +Development-only packages included. Licenses appearing here but not above are +build-time only and are not distributed. + +| License | Packages | +| --- | --- | +| MIT | 769 | +| Apache-2.0 | 162 | +| ISC | 63 | +| BSD-3-Clause | 29 | +| MPL-2.0 | 12 | +| BSD-2-Clause | 10 | +| LGPL-3.0-or-later | 10 | +| OFL-1.1 | 4 | +| Apache-2.0 AND LGPL-3.0-or-later | 3 | +| UNDECLARED | 2 | +| (BSD-2-Clause OR MIT OR Apache-2.0) | 1 | +| (CC-BY-4.0 AND OFL-1.1 AND MIT) | 1 | +| (MIT AND Zlib) | 1 | +| (MIT OR CC0-1.0) | 1 | +| (MPL-2.0 OR Apache-2.0) | 1 | +| (WTFPL OR MIT) | 1 | +| 0BSD | 1 | +| Apache-2.0 AND LGPL-3.0-or-later AND MIT | 1 | +| BlueOak-1.0.0 | 1 | +| EPL-2.0 | 1 | +| Python-2.0 | 1 | +| Unlicense | 1 | +| Zlib | 1 | +| Zlib AND LicenseRef-Tinyland-Proprietary | 1 | + +## Undeclared Licenses + +2 resolved third-party package(s) declare no license field in the lockfile: + +- `@better-fetch/fetch@1.1.21` +- `khroma@2.1.0` + +Each one is an unreviewed license and a follow-up, not a silent MIT. + +The lockfile also carries 3 workspace link(s) — `@blog/agent`, `@blog/pulse-client`, `@blog/pulse-core` — which are this repository's own code under its CC0 dedication. They are not third party and are excluded from every figure above. diff --git a/docs/build-metrics.md b/docs/build-metrics.md index c2f1be01..dc8edfd5 100644 --- a/docs/build-metrics.md +++ b/docs/build-metrics.md @@ -1,9 +1,25 @@ # Build Metrics Baseline -> Superseded 2026-08-28: Vite 8 shipped (`package.json` pins `^8.0.14`). The -> numbers below are the Vite 6.4.1 baseline, kept for comparison only. +- **Status:** STALE — superseded 2026-08-28. Vite 8 shipped and the toolchain + below no longer exists in this repo. The numbers are kept as the Vite 6.4.1 + comparison point, not as a description of the current build. +- **Captured:** 2026-02-11 on branch `feature/sprint3-week8` (commit `9aac474`) +- **Checked by:** `scripts/truth-surfaces.mjs`. The toolchain table below is + machine-compared against `package.json`. If the versions match again this + page must drop the STALE marker; while they differ it must carry it. A + metrics page cannot silently keep presenting itself as current (TIN-161). -Captured: 2026-02-11 | Branch: `feature/sprint3-week8` | Vite 6.4.1 +## Toolchain At Capture + +Declared ranges read from `9aac474:package.json`, the commit that added this +file — not the resolved versions, so the comparison is like-for-like against +`package.json` today. + +| Package | Declared at capture | +| --- | --- | +| `vite` | ^6.4.1 | +| `svelte` | ^5.19.0 | +| `@sveltejs/kit` | ^2.16.0 | ## Build Time @@ -49,4 +65,17 @@ All heavy chunks are lazy-loaded — they don't affect first-load performance. ## Vite 8 / Rolldown Status -**NOGO** — Vite 8.0.0-beta.13 (Feb 2026). Dead code elimination broken for `esm-env` conditions. No stable SvelteKit support. See [issue #16](https://github.com/Jesssullivan/jesssullivan.github.io/issues/16). +**NOGO at capture** — Vite 8.0.0-beta.13 (Feb 2026). Dead code elimination +broken for `esm-env` conditions. No stable SvelteKit support. See +[issue #16](https://github.com/Jesssullivan/jesssullivan.github.io/issues/16). +**Overtaken by events:** `package.json` declares `vite ^8.0.14` as of +2026-08-28, so this row records what was true in February and not a current +posture. + +## Recapture + +There is no measured replacement for this page yet. Producing one needs a real +build, which is the rest of TIN-161; until then the STALE marker and the +machine check are what keep the page honest. When it is recaptured, update the +toolchain table in the same commit as the numbers — the check pairs them +deliberately, so metrics cannot land without the toolchain that produced them. diff --git a/scripts/run-vitest-bazel.mjs b/scripts/run-vitest-bazel.mjs index 173c30fd..3ae98773 100644 --- a/scripts/run-vitest-bazel.mjs +++ b/scripts/run-vitest-bazel.mjs @@ -48,6 +48,10 @@ function copyInputsToBuildRoot() { for (const file of [ '.npmrc', + // TIN-161 truth surfaces: scripts/truth-surfaces.test.mts checks these + // documents against package-lock.json, so they travel with it. + 'THIRD-PARTY-LICENSES.md', + 'docs/build-metrics.md', 'package-lock.json', 'package.json', 'pnpm-lock.yaml', diff --git a/scripts/truth-surfaces.mjs b/scripts/truth-surfaces.mjs new file mode 100644 index 00000000..924a6e23 --- /dev/null +++ b/scripts/truth-surfaces.mjs @@ -0,0 +1,498 @@ +#!/usr/bin/env node + +/** + * Truth surfaces (TIN-161). + * + * Three static surfaces in this repo claimed to describe the build and lied + * quietly as the build moved underneath them: + * + * - `THIRD-PARTY-LICENSES.md` was hand-maintained. By 2026-08-28 it listed + * `shiki 3.22.0` (actual 4.2.0), `vite 6.4.1` (actual 8.0.14), + * `svelte 5.50.1` (actual 5.55.4), `sharp 0.33.5` (actual 0.34.5), + * `mermaid` as a RUNTIME dependency it no longer is, and omitted roughly + * two thirds of the direct dependency set. Nothing failed, because nothing + * checked. + * - `src/routes/THIRD-PARTY-LICENSES/+page.svelte` is the copy the public + * actually reads — it is prerendered and linked from the site footer — and + * it carried the same drift plus a "243 production packages" summary that + * understated Apache-2.0 by two orders of magnitude. Regenerating only the + * Markdown would have left the published page lying, so the page is now + * rendered from `src/lib/data/third-party-licenses.json`, which this module + * generates and byte-compares alongside the Markdown. + * - `docs/build-metrics.md` was captured once on 2026-02-11 against Vite + * 6.4.1 on a branch that no longer exists, and was titled "Baseline" with + * no marker saying the tree it measured is gone. + * + * The fix is not a fresher hand-edit. It is to derive the license surfaces + * from a checked-in machine-readable source and to make staleness FAIL rather + * than accumulate. + * + * Derivation source: `package-lock.json` (npm lockfile v3). It is current with + * `package.json` and with the pnpm resolutions, and — unlike `pnpm-lock.yaml` + * — it carries a `license` field per package. Every figure this module emits + * comes from there; nothing is inferred, and a dependency whose lockfile entry + * declares no license is printed as UNDECLARED rather than guessed. + * + * Deliberately dependency-free so it runs under plain node, without an install + * step, in a pre-commit hook, in CI, or from the vitest suite. The one + * exception is the provenance check, which shells out to `git` and is + * therefore only run where a `.git` directory exists; CI runs this module at + * the repository root for exactly that reason. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const LICENSES_DOC = 'THIRD-PARTY-LICENSES.md'; +export const LICENSES_DATA = 'src/lib/data/third-party-licenses.json'; +export const BUILD_METRICS_DOC = 'docs/build-metrics.md'; +export const GENERATED_MARKER = ''; + +/** Toolchain packages `docs/build-metrics.md` must name at capture time. */ +export const BUILD_METRICS_TOOLCHAIN = ['vite', 'svelte', '@sveltejs/kit']; + +/** + * Every manifest key that declares a direct dependency. All four are read even + * though this repository currently uses two: a surface that silently ignores + * `optionalDependencies` would publish an incomplete attribution the first time + * one was added, and nothing would say so. + */ +export const DEPENDENCY_GROUPS = [ + { key: 'dependencies', field: 'runtime', heading: 'Runtime Dependencies' }, + { key: 'devDependencies', field: 'build', heading: 'Build / Dev Dependencies' }, + { key: 'optionalDependencies', field: 'optional', heading: 'Optional Dependencies' }, + { key: 'peerDependencies', field: 'peer', heading: 'Peer Dependencies' }, +]; + +const readJson = (root, relative) => JSON.parse(readFileSync(join(root, relative), 'utf-8')); + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const NODE_MODULES = 'node_modules/'; + +/** `node_modules/a/node_modules/@s/b` is an install of `@s/b`, not of `a`. */ +const packageNameFromInstallPath = (path) => path.slice(path.lastIndexOf(NODE_MODULES) + NODE_MODULES.length); + +const histogram = (packages) => { + const counts = new Map(); + for (const { license } of packages) { + const key = license ?? 'UNDECLARED'; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); +}; + +/** + * Direct dependencies with the version and license the lockfile actually + * resolved, plus honest aggregate figures for the resolved tree. + * + * `lock.packages` is keyed by INSTALL PATH, not by package: a package that npm + * cannot hoist to the root is recorded once per place it lands + * (`@opentelemetry/core` has 21 such entries here). Counting keys therefore + * overstates the package count, so the tree figures deduplicate on + * `name@version` and drop workspace links, which are this repository's own code + * and carry neither a version nor a license. + */ +export function buildLicenseInventory(root) { + const manifest = readJson(root, 'package.json'); + const lock = readJson(root, 'package-lock.json'); + const entries = lock.packages ?? {}; + + const groups = DEPENDENCY_GROUPS.map(({ key, field, heading }) => ({ + key, + field, + heading, + rows: Object.keys(manifest[key] ?? {}) + .sort() + .map((name) => { + const entry = entries[`${NODE_MODULES}${name}`]; + return { + name, + specifier: manifest[key][name], + version: entry?.version ?? 'UNRESOLVED', + license: entry?.license ?? 'UNDECLARED', + resolved: entry?.resolved ?? null, + }; + }), + })).filter((group) => group.rows.length > 0); + + const installs = Object.entries(entries).filter(([path]) => path.startsWith(NODE_MODULES)); + const packages = new Map(); + for (const [path, entry] of installs) { + const name = packageNameFromInstallPath(path); + const key = `${name}@${entry.version ?? 'UNRESOLVED'}`; + const existing = packages.get(key); + if (!existing) { + packages.set(key, { + name, + version: entry.version ?? null, + license: entry.license ?? null, + link: entry.link === true, + dev: entry.dev === true, + }); + continue; + } + // One package installed in several places is still one package, and it + // is a runtime package if ANY of those installs is outside the dev tree. + existing.dev = existing.dev && entry.dev === true; + existing.license ??= entry.license ?? null; + } + + const resolved = [...packages.values()]; + const thirdParty = resolved.filter((entry) => !entry.link); + const workspace = resolved.filter((entry) => entry.link); + const runtime = thirdParty.filter((entry) => !entry.dev); + + return { + groups, + ...Object.fromEntries(groups.map((group) => [group.field, group.rows])), + tree: { + installCount: installs.length, + packageCount: thirdParty.length, + runtimePackageCount: runtime.length, + licenseHistogram: histogram(thirdParty), + runtimeLicenseHistogram: histogram(runtime), + undeclared: thirdParty + .filter((entry) => entry.license === null) + .map((entry) => `${entry.name}@${entry.version}`) + .sort(), + workspacePackages: workspace.map((entry) => entry.name).sort(), + }, + }; +} + +const row = ({ name, specifier, version, license, resolved }) => + `| ${name} | \`${specifier}\` | ${version} | ${license} | ${resolved ? `<${resolved}>` : '—'} |`; + +const table = (rows) => + [ + '| Package | Declared | Resolved | License | Source |', + '| --- | --- | --- | --- | --- |', + ...rows.map(row), + ].join('\n'); + +const histogramTable = (counts) => + ['| License | Packages |', '| --- | --- |', ...counts.map(([license, count]) => `| ${license} | ${count} |`)].join( + '\n', + ); + +/** Render the whole document. Pure: same inventory in, same bytes out. */ +export function renderLicensesDoc(inventory) { + const { tree } = inventory; + const sections = inventory.groups.map((group) => `## ${group.heading}\n\n${table(group.rows)}`).join('\n\n'); + + const undeclaredSection = + tree.undeclared.length === 0 + ? 'Every resolved third-party package declares a license.' + : `${tree.undeclared.length} resolved third-party package(s) declare no license field in the lockfile:\n\n${tree.undeclared + .map((name) => `- \`${name}\``) + .join('\n')}\n\nEach one is an unreviewed license and a follow-up, not a silent MIT.`; + + const workspaceSection = + tree.workspacePackages.length === 0 + ? '' + : `\nThe lockfile also carries ${tree.workspacePackages.length} workspace link(s) — ${tree.workspacePackages + .map((name) => `\`${name}\``) + .join( + ', ', + )} — which are this repository's own code under its CC0 dedication. They are not third party and are excluded from every figure above.\n`; + + return `# Third-Party Licenses + +${GENERATED_MARKER} + +This project (transscendsurvival.org) is dedicated to the public domain under +[CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/). + +Every figure below is derived from \`package-lock.json\` — the package name and +its declared range from \`package.json\`, the resolved version, license string +and source URL from the lockfile entry for that exact resolution. Nothing here +is hand-entered, so nothing here can drift without \`scripts/truth-surfaces.mjs\` +failing. + +Regenerate with: + +\`\`\`sh +node scripts/truth-surfaces.mjs --write +\`\`\` + +That command writes this file and \`${LICENSES_DATA}\`, which +\`src/routes/THIRD-PARTY-LICENSES/+page.svelte\` renders — the published page and +this document are the same data, so they cannot disagree. + +A dependency whose lockfile entry declares no license is printed as +\`UNDECLARED\`. It is never guessed from the package name, the organisation, or +a sibling version. + +The previous revision of this file was hand-maintained and had drifted: it +listed \`shiki 3.22.0\`, \`vite 6.4.1\`, \`svelte 5.50.1\` and \`sharp 0.33.5\` +against resolutions of 4.2.0, 8.0.14, 5.55.4 and 0.34.5, carried \`mermaid\` as +a runtime dependency it no longer is, and omitted most of the direct set. + +${sections} + +## Resolved Tree + +\`package-lock.json\` records ${tree.installCount} installs under \`node_modules/\`. +That is a count of install paths, not of packages: npm records a package once +per place it lands, so a dependency it cannot hoist appears many times over. +Deduplicated on \`name@version\`, the tree is **${tree.packageCount}** distinct +third-party packages, of which **${tree.runtimePackageCount}** are reachable +outside \`devDependencies\`. + +### Runtime Packages + +This is the set that governs attribution: the site is a prerendered static +build, so only these licenses travel to a reader. + +${histogramTable(tree.runtimeLicenseHistogram)} + +### Full Resolved Tree + +Development-only packages included. Licenses appearing here but not above are +build-time only and are not distributed. + +${histogramTable(tree.licenseHistogram)} + +## Undeclared Licenses + +${undeclaredSection} +${workspaceSection}`; +} + +/** + * The same inventory as JSON, for the prerendered route. Written by `--write` + * and byte-compared by `checkTruthSurfaces`, so the published page is covered + * by the same guard as the Markdown. + */ +export function renderLicensesData(inventory) { + return `${JSON.stringify( + { + generatedBy: 'scripts/truth-surfaces.mjs', + source: 'package-lock.json', + groups: inventory.groups.map(({ key, heading, rows }) => ({ key, heading, rows })), + tree: inventory.tree, + }, + null, + '\t', + )}\n`; +} + +/** Metadata `docs/build-metrics.md` must declare about its own capture. */ +export function readBuildMetricsHeader(root) { + const body = readFileSync(join(root, BUILD_METRICS_DOC), 'utf-8'); + const field = (label) => { + const match = body.match(new RegExp(`^- \\*\\*${label}:\\*\\* (.+)$`, 'm')); + return match ? match[1].trim() : null; + }; + const toolchain = {}; + for (const name of BUILD_METRICS_TOOLCHAIN) { + const match = body.match(new RegExp(`^\\| \`${escapeRegExp(name)}\` \\| ([^|]+) \\|`, 'm')); + toolchain[name] = match ? match[1].trim() : null; + } + const commit = body.match(/^- \*\*Captured:\*\* .*\(commit `([0-9a-f]{7,40})`\)/m); + return { + body, + status: field('Status'), + capturedAt: field('Captured'), + captureCommit: commit ? commit[1] : null, + toolchain, + }; +} + +/** + * The freshness rule, stated so it can fail in both directions: + * + * - the document must declare a Status, a capture date, and the version of + * every toolchain package it was captured against; + * - if any of those versions is not what `package.json` declares today, the + * Status must say STALE. Bumping vite without recapturing turns the page + * red; recapturing on the current toolchain and updating the versions lets + * it go green again. + * + * On its own this checks the label rather than the measurement, which is why + * `checkBuildMetricsProvenance` exists: without it the cheapest way to green is + * to retype the toolchain row, and the guard would reward falsifying the one + * part of the page that records historical fact. + */ +export function checkBuildMetricsFreshness(root) { + if (!existsSync(join(root, BUILD_METRICS_DOC))) { + return [`${BUILD_METRICS_DOC}: missing. The build metrics surface may not be deleted to silence its check.`]; + } + + const violations = []; + const manifest = readJson(root, 'package.json'); + const declared = { ...manifest.dependencies, ...manifest.devDependencies }; + const { status, capturedAt, toolchain } = readBuildMetricsHeader(root); + + if (!status) violations.push(`${BUILD_METRICS_DOC}: no "- **Status:** ..." line.`); + if (!capturedAt) violations.push(`${BUILD_METRICS_DOC}: no "- **Captured:** ..." line.`); + + const drifted = []; + for (const name of BUILD_METRICS_TOOLCHAIN) { + const capturedVersion = toolchain[name]; + if (!capturedVersion) { + violations.push( + `${BUILD_METRICS_DOC}: does not record the \`${name}\` version it was captured against. ` + + 'A metrics page that will not say which toolchain produced it cannot be checked for staleness.', + ); + continue; + } + if (capturedVersion !== declared[name]) { + drifted.push( + `${name} captured at ${capturedVersion}, package.json ` + + (declared[name] === undefined ? 'no longer declares it' : `declares ${declared[name]}`), + ); + } + } + + if (drifted.length > 0 && !/STALE/.test(status ?? '')) { + violations.push( + `${BUILD_METRICS_DOC}: the toolchain moved (${drifted.join('; ')}) but Status does not say STALE. ` + + 'Either recapture the metrics against the current toolchain and update the recorded versions, ' + + 'or mark the page STALE — it may not keep presenting itself as current.', + ); + } + if (drifted.length === 0 && /STALE/.test(status ?? '')) { + violations.push( + `${BUILD_METRICS_DOC}: Status says STALE but the recorded toolchain matches package.json. ` + + 'Drop the marker rather than leaving a permanent warning nobody reads.', + ); + } + + return violations; +} + +/** Read `:` out of the repository at `root`. */ +export const gitFileReader = + (root) => + (commit, path) => { + const result = spawnSync('git', ['-C', root, 'show', `${commit}:${path}`], { encoding: 'utf-8' }); + return result.status === 0 ? result.stdout : null; + }; + +/** + * The toolchain row is provenance, not a field. + * + * `checkBuildMetricsFreshness` compares the recorded toolchain against + * `package.json` today, which means the cheapest path to green after a Vite + * bump is to retype those three cells and drop the STALE marker — without + * recapturing a single number. That turns the guard into an incentive to + * falsify history. + * + * So the row is validated against the commit the page names as its capture: + * the recorded ranges must be what `:package.json` declared. + * Editing the row now requires moving the capture commit, and moving the + * capture commit is a claim that the measurements were retaken. + * + * `readAtCommit` is injected so this is testable without a git repository; the + * default reads the real one. + */ +export function checkBuildMetricsProvenance(root, readAtCommit = gitFileReader(root)) { + if (!existsSync(join(root, BUILD_METRICS_DOC))) { + return [`${BUILD_METRICS_DOC}: missing. The build metrics surface may not be deleted to silence its check.`]; + } + + const { captureCommit, toolchain } = readBuildMetricsHeader(root); + if (!captureCommit) { + return [ + `${BUILD_METRICS_DOC}: the "- **Captured:** ..." line does not name the commit it was captured at ` + + '(expected a trailing "(commit ``)"). Without it the toolchain table is an unverifiable claim ' + + 'that can be retyped to make the freshness check pass.', + ]; + } + + const source = readAtCommit(captureCommit, 'package.json'); + if (source === null) { + return [ + `${BUILD_METRICS_DOC}: cannot read package.json at the declared capture commit \`${captureCommit}\`. ` + + 'The page names a commit that this repository does not have.', + ]; + } + + let declaredAtCapture; + try { + const parsed = JSON.parse(source); + declaredAtCapture = { ...parsed.dependencies, ...parsed.devDependencies }; + } catch { + return [`${BUILD_METRICS_DOC}: package.json at capture commit \`${captureCommit}\` is not valid JSON.`]; + } + + const violations = []; + for (const name of BUILD_METRICS_TOOLCHAIN) { + const recorded = toolchain[name]; + // A missing row is already a freshness violation; do not double-report. + if (recorded === null) continue; + const actual = declaredAtCapture[name]; + if (recorded !== actual) { + violations.push( + `${BUILD_METRICS_DOC}: records \`${name}\` at ${recorded} "at capture", but ` + + `\`${captureCommit}:package.json\` declares ` + + `${actual === undefined ? 'no such dependency' : actual}. ` + + 'The toolchain row is provenance — it must be what the capture commit declared. ' + + 'Recapture the metrics and name the new commit rather than editing the row.', + ); + } + } + return violations; +} + +/** Everything, as a violation list. Empty means every surface is truthful. */ +export function checkTruthSurfaces(root) { + const violations = []; + const inventory = buildLicenseInventory(root); + + for (const [surface, expected] of [ + [LICENSES_DOC, renderLicensesDoc(inventory)], + [LICENSES_DATA, renderLicensesData(inventory)], + ]) { + const path = join(root, surface); + if (!existsSync(path)) { + violations.push(`${surface}: missing. Run \`node scripts/truth-surfaces.mjs --write\`.`); + continue; + } + if (readFileSync(path, 'utf-8') !== expected) { + violations.push( + `${surface} is out of date with package-lock.json. Run \`node scripts/truth-surfaces.mjs --write\`.`, + ); + } + } + + violations.push(...checkBuildMetricsFreshness(root)); + // The provenance check needs git history. The Vitest sandbox is a copied + // tree with no `.git`, so CI runs this module at the repository root as + // well — see the "Truth surfaces" step in .github/workflows/ci.yml. + if (existsSync(join(root, '.git'))) { + violations.push(...checkBuildMetricsProvenance(root)); + } + + return violations; +} + +/* CLI: `--write` regenerates the license surfaces; no flag checks all three. */ +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const root = process.cwd(); + if (process.argv.includes('--write')) { + const { mkdirSync, writeFileSync } = await import('node:fs'); + const { dirname } = await import('node:path'); + const inventory = buildLicenseInventory(root); + for (const [surface, contents] of [ + [LICENSES_DOC, renderLicensesDoc(inventory)], + [LICENSES_DATA, renderLicensesData(inventory)], + ]) { + mkdirSync(dirname(join(root, surface)), { recursive: true }); + writeFileSync(join(root, surface), contents); + process.stdout.write(`wrote ${surface}\n`); + } + } else { + const violations = checkTruthSurfaces(root); + if (violations.length === 0) { + process.stdout.write('truth surfaces OK\n'); + } else { + for (const violation of violations) process.stderr.write(`- ${violation}\n`); + process.exitCode = 1; + } + } +} diff --git a/scripts/truth-surfaces.test.mts b/scripts/truth-surfaces.test.mts new file mode 100644 index 00000000..1c82c6b6 --- /dev/null +++ b/scripts/truth-surfaces.test.mts @@ -0,0 +1,466 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + BUILD_METRICS_DOC, + DEPENDENCY_GROUPS, + LICENSES_DATA, + LICENSES_DOC, + buildLicenseInventory, + checkBuildMetricsFreshness, + checkBuildMetricsProvenance, + checkTruthSurfaces, + renderLicensesData, + renderLicensesDoc, +} from './truth-surfaces.mjs'; + +/** + * TIN-161 — the static surfaces that claimed to describe the build. + * + * `THIRD-PARTY-LICENSES.md` was hand-maintained and had drifted years of + * versions; `src/routes/THIRD-PARTY-LICENSES/+page.svelte` is the copy the + * public actually reads and had drifted the same way; `docs/build-metrics.md` + * was a February snapshot titled "Baseline". None could fail, because nothing + * checked them. These tests are the check, and most of them exist to prove the + * check can go red: a guard that has only ever been seen passing is an + * unfailable assertion. + */ + +const temps: string[] = []; +afterEach(() => { + while (temps.length > 0) { + rmSync(temps.pop() as string, { recursive: true, force: true }); + } +}); + +type LockEntry = { version?: string; license?: string; resolved?: string; dev?: boolean; link?: boolean }; + +type Fixture = { + manifest?: Record; + dependencies?: Record; + devDependencies?: Record; + /** Keyed by install path; a bare name is shorthand for `node_modules/`. */ + lock?: Record; + metrics?: string; + licensesDoc?: string; + licensesData?: string; +}; + +const CAPTURE_COMMIT = '9aac474'; + +const METRICS_TEMPLATE = (status: string, vite: string, svelte: string, kit: string) => `# Build Metrics Baseline + +- **Status:** ${status} +- **Captured:** 2026-02-11 on branch \`feature/sprint3-week8\` (commit \`${CAPTURE_COMMIT}\`) + +## Toolchain At Capture + +| Package | Declared at capture | +| --- | --- | +| \`vite\` | ${vite} | +| \`svelte\` | ${svelte} | +| \`@sveltejs/kit\` | ${kit} | +`; + +/** A `readAtCommit` that answers for one commit only, like git history does. */ +const commitReader = (commit: string, manifest: Record) => (asked: string, path: string) => + asked === commit && path === 'package.json' ? JSON.stringify(manifest) : null; + +const write = (root: string, relative: string, contents: string) => { + mkdirSync(dirname(join(root, relative)), { recursive: true }); + writeFileSync(join(root, relative), contents); +}; + +function fixture({ + manifest, + dependencies = {}, + devDependencies = {}, + lock = {}, + metrics, + licensesDoc, + licensesData, +}: Fixture): string { + const root = mkdtempSync(join(tmpdir(), 'truth-surfaces-')); + temps.push(root); + + write(root, 'package.json', JSON.stringify(manifest ?? { dependencies, devDependencies }, null, 2)); + write( + root, + 'package-lock.json', + JSON.stringify( + { + lockfileVersion: 3, + packages: Object.fromEntries( + Object.entries(lock).map(([path, entry]) => [ + path.startsWith('node_modules/') ? path : `node_modules/${path}`, + entry, + ]), + ), + }, + null, + 2, + ), + ); + + write(root, BUILD_METRICS_DOC, metrics ?? METRICS_TEMPLATE('CURRENT', '^6.4.1', '^5.19.0', '^2.16.0')); + const inventory = buildLicenseInventory(root); + write(root, LICENSES_DOC, licensesDoc ?? renderLicensesDoc(inventory)); + write(root, LICENSES_DATA, licensesData ?? renderLicensesData(inventory)); + return root; +} + +describe('license surface is derived, not maintained', () => { + it('reads version, license and source from the lockfile entry for the resolved version', () => { + const root = fixture({ + dependencies: { shiki: '^4.2.0' }, + devDependencies: { vite: '^8.0.14' }, + lock: { + shiki: { version: '4.2.0', license: 'MIT', resolved: 'https://registry.npmjs.org/shiki/-/shiki-4.2.0.tgz' }, + vite: { version: '8.0.14', license: 'MIT', dev: true }, + }, + }); + const inventory = buildLicenseInventory(root); + expect(inventory.runtime).toEqual([ + { + name: 'shiki', + specifier: '^4.2.0', + version: '4.2.0', + license: 'MIT', + resolved: 'https://registry.npmjs.org/shiki/-/shiki-4.2.0.tgz', + }, + ]); + expect(inventory.build[0]).toMatchObject({ name: 'vite', version: '8.0.14' }); + // `resolved` is the only field that reveals a package that did not come + // from the registry, so the document has to print it. + expect(renderLicensesDoc(inventory)).toContain(''); + }); + + it('prints UNDECLARED rather than guessing when the lockfile declares no license', () => { + const root = fixture({ + dependencies: { khroma: '^2.1.0' }, + lock: { khroma: { version: '2.1.0' } }, + }); + const inventory = buildLicenseInventory(root); + expect(inventory.runtime[0].license).toBe('UNDECLARED'); + expect(inventory.tree.undeclared).toEqual(['khroma@2.1.0']); + expect(renderLicensesDoc(inventory)).toContain('UNDECLARED'); + // The failure mode this replaces: a hand-written MIT for a package + // nobody read the license of. + expect(renderLicensesDoc(inventory)).not.toContain('| khroma | `^2.1.0` | 2.1.0 | MIT |'); + }); + + it('publishes every dependency group the manifest declares, not just the two this repo uses', () => { + const root = fixture({ + manifest: { + dependencies: { shiki: '^4.2.0' }, + devDependencies: { vite: '^8.0.14' }, + optionalDependencies: { fsevents: '^2.3.3' }, + peerDependencies: { svelte: '^5.55.4' }, + }, + lock: { + shiki: { version: '4.2.0', license: 'MIT' }, + vite: { version: '8.0.14', license: 'MIT' }, + fsevents: { version: '2.3.3', license: 'MIT' }, + svelte: { version: '5.55.4', license: 'MIT' }, + }, + }); + const inventory = buildLicenseInventory(root); + expect(inventory.optional).toEqual([ + { name: 'fsevents', specifier: '^2.3.3', version: '2.3.3', license: 'MIT', resolved: null }, + ]); + expect(inventory.peer[0]).toMatchObject({ name: 'svelte', version: '5.55.4' }); + // An omitted group used to be invisible: the package simply never + // appeared, and no figure moved. + const rendered = renderLicensesDoc(inventory); + expect(rendered).toContain('## Optional Dependencies'); + expect(rendered).toContain('| fsevents |'); + expect(rendered).toContain('## Peer Dependencies'); + }); + + it('omits the heading for a dependency group the manifest does not declare', () => { + const root = fixture({ dependencies: { shiki: '^4.2.0' }, lock: { shiki: { version: '4.2.0', license: 'MIT' } } }); + expect(renderLicensesDoc(buildLicenseInventory(root))).not.toContain('Optional Dependencies'); + }); + + it('counts a package installed at several paths once, not once per path', () => { + const root = fixture({ + dependencies: { a: '^1.0.0' }, + lock: { + a: { version: '1.0.0', license: 'MIT' }, + 'node_modules/a/node_modules/dup': { version: '2.0.0', license: 'ISC' }, + 'node_modules/b/node_modules/dup': { version: '2.0.0', license: 'ISC' }, + 'node_modules/c/node_modules/dup': { version: '3.0.0', license: 'ISC' }, + }, + }); + const { tree } = buildLicenseInventory(root); + // Four install paths, three packages: `dup@2.0.0` lands twice. + expect(tree.installCount).toBe(4); + expect(tree.packageCount).toBe(3); + expect(tree.licenseHistogram).toEqual([ + ['ISC', 2], + ['MIT', 1], + ]); + }); + + it("excludes this repository's own workspace links from the third-party figures", () => { + const root = fixture({ + dependencies: { a: '^1.0.0' }, + lock: { + a: { version: '1.0.0', license: 'MIT' }, + '@blog/pulse-core': { link: true }, + '@blog/pulse-client': { link: true }, + }, + }); + const { tree } = buildLicenseInventory(root); + expect(tree.packageCount).toBe(1); + expect(tree.workspacePackages).toEqual(['@blog/pulse-client', '@blog/pulse-core']); + // Workspace links carry no license field. Counting them as third party + // published this repo's own code as an unlicensed dependency. + expect(tree.undeclared).toEqual([]); + }); + + it('separates the runtime histogram from the dev-inclusive one', () => { + const root = fixture({ + dependencies: { a: '^1.0.0' }, + devDependencies: { b: '^1.0.0' }, + lock: { + a: { version: '1.0.0', license: 'MIT' }, + b: { version: '1.0.0', license: 'LGPL-3.0-or-later', dev: true }, + }, + }); + const { tree } = buildLicenseInventory(root); + expect(tree.runtimePackageCount).toBe(1); + expect(tree.runtimeLicenseHistogram).toEqual([['MIT', 1]]); + // A copyleft build tool is not distributed by a prerendered static + // site, and blending it into one number made the attribution useless. + expect(tree.licenseHistogram).toEqual([ + ['LGPL-3.0-or-later', 1], + ['MIT', 1], + ]); + }); + + it('treats a package as runtime when any of its installs is outside the dev tree', () => { + const root = fixture({ + dependencies: { a: '^1.0.0' }, + lock: { + a: { version: '1.0.0', license: 'MIT' }, + 'node_modules/tool/node_modules/shared': { version: '1.0.0', license: 'ISC', dev: true }, + 'node_modules/a/node_modules/shared': { version: '1.0.0', license: 'ISC' }, + }, + }); + const { tree } = buildLicenseInventory(root); + expect(tree.runtimePackageCount).toBe(2); + expect(tree.runtimeLicenseHistogram).toEqual([ + ['ISC', 1], + ['MIT', 1], + ]); + }); + + it('goes red when a resolved version moves and the document does not', () => { + const root = fixture({ + dependencies: { shiki: '^4.2.0' }, + // The build-metrics half of the check shares this fixture root, so the + // toolchain it records has to be declared here too — otherwise a + // build-metrics violation would mask the license one being asserted. + devDependencies: { vite: '^6.4.1', svelte: '^5.19.0', '@sveltejs/kit': '^2.16.0' }, + lock: { shiki: { version: '4.2.0', license: 'MIT' } }, + }); + expect(checkTruthSurfaces(root)).toEqual([]); + + // Bump the lockfile the way a dependency PR would, leaving the + // committed document behind — the exact drift that produced + // `shiki 3.22.0` in a tree resolving 4.2.0. + writeFileSync( + join(root, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 3, + packages: { 'node_modules/shiki': { version: '4.3.0', license: 'MIT' } }, + }), + ); + expect(checkTruthSurfaces(root).join('\n')).toMatch(/THIRD-PARTY-LICENSES\.md is out of date/); + }); + + it('goes red when the data the published page renders drifts, not just the Markdown', () => { + const root = fixture({ + dependencies: { shiki: '^4.2.0' }, + devDependencies: { vite: '^6.4.1', svelte: '^5.19.0', '@sveltejs/kit': '^2.16.0' }, + lock: { shiki: { version: '4.2.0', license: 'MIT' } }, + }); + expect(checkTruthSurfaces(root)).toEqual([]); + + // The original defect: the Markdown was regenerated and the prerendered + // route — the copy the public reads — was left behind. + writeFileSync(join(root, LICENSES_DATA), '{"generatedBy":"a hand"}\n'); + expect(checkTruthSurfaces(root).join('\n')).toMatch(/third-party-licenses\.json is out of date/); + }); + + it('reports a missing surface as a violation instead of throwing', () => { + const root = fixture({ dependencies: { shiki: '^4.2.0' }, lock: { shiki: { version: '4.2.0', license: 'MIT' } } }); + rmSync(join(root, LICENSES_DOC)); + rmSync(join(root, LICENSES_DATA)); + const violations = checkTruthSurfaces(root).join('\n'); + expect(violations).toMatch(/THIRD-PARTY-LICENSES\.md: missing/); + expect(violations).toMatch(/third-party-licenses\.json: missing/); + }); + + it('renders the same bytes when the manifest and lockfile key order is reversed', () => { + // The claim worth testing is stability across input order, not that a + // pure function called twice agrees with itself — that cannot fail. + const dependencies = { shiki: '^4.2.0', marked: '^4.3.0', dompurify: '^3.4.13' }; + const lock = { + shiki: { version: '4.2.0', license: 'MIT' }, + marked: { version: '4.3.0', license: 'MIT' }, + dompurify: { version: '3.4.13', license: '(MPL-2.0 OR Apache-2.0)' }, + }; + const reverse = (value: Record) => Object.fromEntries(Object.entries(value).reverse()); + + const forward = renderLicensesDoc(buildLicenseInventory(fixture({ dependencies, lock }))); + const backward = renderLicensesDoc( + buildLicenseInventory(fixture({ dependencies: reverse(dependencies), lock: reverse(lock) })), + ); + expect(backward).toBe(forward); + }); +}); + +describe('build-metrics freshness fails in both directions', () => { + const deps = { vite: '^6.4.1', svelte: '^5.19.0', '@sveltejs/kit': '^2.16.0' }; + + it('passes when the recorded toolchain matches and the page does not claim to be stale', () => { + const root = fixture({ devDependencies: deps }); + expect(checkBuildMetricsFreshness(root)).toEqual([]); + }); + + it('fails when the toolchain moved and the page still presents itself as current', () => { + const root = fixture({ devDependencies: { ...deps, vite: '^8.0.14' } }); + expect(checkBuildMetricsFreshness(root).join('\n')).toMatch(/the toolchain moved .* but Status does not say STALE/s); + }); + + it('passes once the page is marked STALE against a moved toolchain', () => { + const root = fixture({ + devDependencies: { ...deps, vite: '^8.0.14' }, + metrics: METRICS_TEMPLATE('STALE — superseded', '^6.4.1', '^5.19.0', '^2.16.0'), + }); + expect(checkBuildMetricsFreshness(root)).toEqual([]); + }); + + it('fails when the page keeps a STALE marker the toolchain no longer justifies', () => { + const root = fixture({ + devDependencies: deps, + metrics: METRICS_TEMPLATE('STALE — superseded', '^6.4.1', '^5.19.0', '^2.16.0'), + }); + expect(checkBuildMetricsFreshness(root).join('\n')).toMatch(/Status says STALE but the recorded toolchain matches/); + }); + + it('fails when the page will not say which toolchain produced it', () => { + const root = fixture({ + devDependencies: deps, + metrics: '# Build Metrics Baseline\n\n- **Status:** CURRENT\n- **Captured:** 2026-02-11\n', + }); + const violations = checkBuildMetricsFreshness(root).join('\n'); + expect(violations).toMatch(/does not record the `vite` version/); + expect(violations).toMatch(/does not record the `@sveltejs\/kit` version/); + }); + + it('fails when the page carries no Status line at all', () => { + const root = fixture({ devDependencies: deps, metrics: '# Build Metrics Baseline\n\nnumbers\n' }); + expect(checkBuildMetricsFreshness(root).join('\n')).toMatch(/no "- \*\*Status:\*\* \.\.\." line/); + }); + + it('reports a deleted metrics page as a violation instead of throwing', () => { + const root = fixture({ devDependencies: deps }); + rmSync(join(root, BUILD_METRICS_DOC)); + expect(checkBuildMetricsFreshness(root).join('\n')).toMatch(/docs\/build-metrics\.md: missing/); + }); +}); + +describe('the toolchain row is provenance, not a field', () => { + const capture = { devDependencies: { vite: '^6.4.1', svelte: '^5.19.0', '@sveltejs/kit': '^2.16.0' } }; + + it('passes when the recorded row is what the capture commit declared', () => { + const root = fixture({ devDependencies: capture.devDependencies }); + expect(checkBuildMetricsProvenance(root, commitReader(CAPTURE_COMMIT, capture))).toEqual([]); + }); + + it('fails when the row is retyped to whatever makes the freshness check green', () => { + // Without this, the cheapest path to green after a Vite bump is to edit + // three cells and drop the STALE marker, without recapturing a single + // number — the guard would reward falsifying the provenance. + const root = fixture({ + devDependencies: { vite: '^8.0.14', svelte: '^5.55.4', '@sveltejs/kit': '^2.61.1' }, + metrics: METRICS_TEMPLATE('CURRENT', '^8.0.14', '^5.55.4', '^2.61.1'), + }); + expect(checkBuildMetricsFreshness(root)).toEqual([]); + const violations = checkBuildMetricsProvenance(root, commitReader(CAPTURE_COMMIT, capture)).join('\n'); + expect(violations).toMatch(/records `vite` at \^8\.0\.14 "at capture", but `9aac474:package\.json` declares \^6\.4\.1/); + expect(violations).toMatch(/records `@sveltejs\/kit` at \^2\.61\.1/); + }); + + it('fails when the page will not name the commit it was captured at', () => { + const root = fixture({ + devDependencies: capture.devDependencies, + metrics: METRICS_TEMPLATE('CURRENT', '^6.4.1', '^5.19.0', '^2.16.0').replace(/ \(commit `[0-9a-f]+`\)/, ''), + }); + expect(checkBuildMetricsProvenance(root, commitReader(CAPTURE_COMMIT, capture)).join('\n')).toMatch( + /does not name the commit it was captured at/, + ); + }); + + it('fails when the named commit is not in this repository', () => { + const root = fixture({ + devDependencies: capture.devDependencies, + metrics: METRICS_TEMPLATE('CURRENT', '^6.4.1', '^5.19.0', '^2.16.0').replace(CAPTURE_COMMIT, 'deadbee'), + }); + expect(checkBuildMetricsProvenance(root, commitReader(CAPTURE_COMMIT, capture)).join('\n')).toMatch( + /cannot read package\.json at the declared capture commit `deadbee`/, + ); + }); + + it('is wired into checkTruthSurfaces wherever git history is available', () => { + // Proving the wiring, not just the function: the Vitest sandbox is a + // copied tree with no `.git`, so an unwired provenance check would be + // invisible to every other case here. This fixture has a `.git`, so + // checkTruthSurfaces must reach for history — and fail, because the + // fixture is not a real repository. + const root = fixture({ devDependencies: capture.devDependencies }); + mkdirSync(join(root, '.git'), { recursive: true }); + expect(checkTruthSurfaces(root).join('\n')).toMatch(/cannot read package\.json at the declared capture commit/); + }); +}); + +describe('this repository satisfies every surface', () => { + it('has no truth-surface violations', () => { + expect(checkTruthSurfaces(process.cwd())).toEqual([]); + }); + + it('publishes a license row for every direct dependency, none of them guessed', () => { + const inventory = buildLicenseInventory(process.cwd()); + const declared = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8')); + // Derived from the manifest rather than restating the two keys the + // implementation reads: a group the implementation ignores has to show + // up as a count mismatch, not as silence. + const directCount = Object.entries(declared) + .filter(([key, value]) => key.endsWith('ependencies') && value !== null && typeof value === 'object') + .reduce((total, [, value]) => total + Object.keys(value as Record).length, 0); + const published = inventory.groups.reduce((total, group) => total + group.rows.length, 0); + expect(directCount).toBeGreaterThan(0); + expect(published).toBe(directCount); + expect(inventory.groups.flatMap((group) => group.rows).filter((row) => row.version === 'UNRESOLVED')).toEqual([]); + }); + + it('knows about every dependency group the manifest could declare', () => { + const declared = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8')); + const known = new Set(DEPENDENCY_GROUPS.map((group) => group.key)); + const unknown = Object.keys(declared).filter((key) => key.endsWith('ependencies') && !known.has(key)); + expect(unknown).toEqual([]); + }); + + it('renders the published route from the generated inventory rather than hand-written rows', () => { + const page = readFileSync(join(process.cwd(), 'src/routes/THIRD-PARTY-LICENSES/+page.svelte'), 'utf-8'); + expect(existsSync(join(process.cwd(), LICENSES_DATA))).toBe(true); + expect(page).toContain("import inventory from '$lib/data/third-party-licenses.json'"); + // The defect this replaces: 30-odd hand-written rows that drifted + // independently of the Markdown they claimed to summarise. + expect(page).not.toMatch(/]*>\s*\d+\.\d+\.\d+\s*<\/td>/); + }); +}); diff --git a/src/lib/data/third-party-licenses.json b/src/lib/data/third-party-licenses.json new file mode 100644 index 00000000..3e703512 --- /dev/null +++ b/src/lib/data/third-party-licenses.json @@ -0,0 +1,466 @@ +{ + "generatedBy": "scripts/truth-surfaces.mjs", + "source": "package-lock.json", + "groups": [ + { + "key": "dependencies", + "heading": "Runtime Dependencies", + "rows": [ + { + "name": "@fontsource/crimson-pro", + "specifier": "^5.2.8", + "version": "5.2.8", + "license": "OFL-1.1", + "resolved": "https://registry.npmjs.org/@fontsource/crimson-pro/-/crimson-pro-5.2.8.tgz" + }, + { + "name": "@fontsource/fira-code", + "specifier": "^5.2.7", + "version": "5.2.7", + "license": "OFL-1.1", + "resolved": "https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.2.7.tgz" + }, + { + "name": "@fontsource/inter", + "specifier": "^5.2.8", + "version": "5.2.8", + "license": "OFL-1.1", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz" + }, + { + "name": "@fontsource/raleway", + "specifier": "^5.2.8", + "version": "5.2.8", + "license": "OFL-1.1", + "resolved": "https://registry.npmjs.org/@fontsource/raleway/-/raleway-5.2.8.tgz" + }, + { + "name": "@skeletonlabs/skeleton", + "specifier": "5.0.0", + "version": "5.0.0", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@skeletonlabs/skeleton/-/skeleton-5.0.0.tgz" + }, + { + "name": "@skeletonlabs/skeleton-svelte", + "specifier": "5.0.0", + "version": "5.0.0", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@skeletonlabs/skeleton-svelte/-/skeleton-svelte-5.0.0.tgz" + }, + { + "name": "@tummycrypt/tinyvectors", + "specifier": "https://github.com/tinyland-inc/tinyvectors/archive/refs/tags/v0.3.5.tar.gz", + "version": "0.3.5", + "license": "Zlib", + "resolved": "https://github.com/tinyland-inc/tinyvectors/archive/refs/tags/v0.3.5.tar.gz" + }, + { + "name": "dompurify", + "specifier": "^3.4.13", + "version": "3.4.13", + "license": "(MPL-2.0 OR Apache-2.0)", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz" + }, + { + "name": "flexsearch", + "specifier": "^0.8.212", + "version": "0.8.212", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.8.212.tgz" + }, + { + "name": "marked", + "specifier": "^4.3.0", + "version": "4.3.0", + "license": "MIT", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz" + }, + { + "name": "mdsvex", + "specifier": "^0.12.7", + "version": "0.12.7", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mdsvex/-/mdsvex-0.12.7.tgz" + }, + { + "name": "shiki", + "specifier": "^4.2.0", + "version": "4.2.0", + "license": "MIT", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.2.0.tgz" + }, + { + "name": "tailwindcss", + "specifier": "^4.2.2", + "version": "4.2.2", + "license": "MIT", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz" + } + ] + }, + { + "key": "devDependencies", + "heading": "Build / Dev Dependencies", + "rows": [ + { + "name": "@eslint/js", + "specifier": "^9.39.2", + "version": "9.39.4", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz" + }, + { + "name": "@mermaid-js/mermaid-cli", + "specifier": "^11.15.0", + "version": "11.15.0", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@mermaid-js/mermaid-cli/-/mermaid-cli-11.15.0.tgz" + }, + { + "name": "@playwright/test", + "specifier": "^1.59.1", + "version": "1.59.1", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz" + }, + { + "name": "@sveltejs/adapter-node", + "specifier": "^5.5.7", + "version": "5.5.7", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz" + }, + { + "name": "@sveltejs/adapter-static", + "specifier": "^3.0.10", + "version": "3.0.10", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz" + }, + { + "name": "@sveltejs/kit", + "specifier": "^2.61.1", + "version": "2.61.1", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.61.1.tgz" + }, + { + "name": "@sveltejs/vite-plugin-svelte", + "specifier": "^7.1.2", + "version": "7.1.2", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz" + }, + { + "name": "@tailwindcss/vite", + "specifier": "^4.2.2", + "version": "4.2.2", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz" + }, + { + "name": "@tummycrypt/vite-plugin-a11y", + "specifier": "^0.2.2", + "version": "0.2.2", + "license": "Zlib AND LicenseRef-Tinyland-Proprietary", + "resolved": "https://registry.npmjs.org/@tummycrypt/vite-plugin-a11y/-/vite-plugin-a11y-0.2.2.tgz" + }, + { + "name": "@types/node", + "specifier": "^25.2.3", + "version": "25.5.0", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz" + }, + { + "name": "@vitest/coverage-v8", + "specifier": "^4.1.4", + "version": "4.1.4", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz" + }, + { + "name": "eslint", + "specifier": "^9.39.2", + "version": "9.39.4", + "license": "MIT", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz" + }, + { + "name": "eslint-config-prettier", + "specifier": "^10.1.8", + "version": "10.1.8", + "license": "MIT", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz" + }, + { + "name": "eslint-plugin-svelte", + "specifier": "^3.17.0", + "version": "3.17.0", + "license": "MIT", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.17.0.tgz" + }, + { + "name": "pagefind", + "specifier": "^1.5.2", + "version": "1.5.2", + "license": "MIT", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz" + }, + { + "name": "prettier", + "specifier": "^3.8.1", + "version": "3.8.1", + "license": "MIT", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz" + }, + { + "name": "prettier-plugin-svelte", + "specifier": "^3.4.1", + "version": "3.5.1", + "license": "MIT", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz" + }, + { + "name": "puppeteer", + "specifier": "^24.43.1", + "version": "24.43.1", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.43.1.tgz" + }, + { + "name": "rehype-slug", + "specifier": "^6.0.0", + "version": "6.0.0", + "license": "MIT", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz" + }, + { + "name": "rollup-plugin-visualizer", + "specifier": "^6.0.5", + "version": "6.0.11", + "license": "MIT", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-6.0.11.tgz" + }, + { + "name": "serve", + "specifier": "^14.2.5", + "version": "14.2.6", + "license": "MIT", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz" + }, + { + "name": "sharp", + "specifier": "^0.34.5", + "version": "0.34.5", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz" + }, + { + "name": "svelte", + "specifier": "^5.55.4", + "version": "5.55.4", + "license": "MIT", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz" + }, + { + "name": "svelte-check", + "specifier": "^4.4.5", + "version": "4.4.5", + "license": "MIT", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz" + }, + { + "name": "tsx", + "specifier": "^4.21.0", + "version": "4.21.0", + "license": "MIT", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz" + }, + { + "name": "typescript", + "specifier": "^5.9.3", + "version": "5.9.3", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" + }, + { + "name": "typescript-eslint", + "specifier": "^8.58.2", + "version": "8.58.2", + "license": "MIT", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz" + }, + { + "name": "vite", + "specifier": "^8.0.14", + "version": "8.0.14", + "license": "MIT", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz" + }, + { + "name": "vitest", + "specifier": "^4.1.4", + "version": "4.1.4", + "license": "MIT", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz" + } + ] + } + ], + "tree": { + "installCount": 1150, + "packageCount": 1078, + "runtimePackageCount": 361, + "licenseHistogram": [ + [ + "MIT", + 769 + ], + [ + "Apache-2.0", + 162 + ], + [ + "ISC", + 63 + ], + [ + "BSD-3-Clause", + 29 + ], + [ + "MPL-2.0", + 12 + ], + [ + "BSD-2-Clause", + 10 + ], + [ + "LGPL-3.0-or-later", + 10 + ], + [ + "OFL-1.1", + 4 + ], + [ + "Apache-2.0 AND LGPL-3.0-or-later", + 3 + ], + [ + "UNDECLARED", + 2 + ], + [ + "(BSD-2-Clause OR MIT OR Apache-2.0)", + 1 + ], + [ + "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + 1 + ], + [ + "(MIT AND Zlib)", + 1 + ], + [ + "(MIT OR CC0-1.0)", + 1 + ], + [ + "(MPL-2.0 OR Apache-2.0)", + 1 + ], + [ + "(WTFPL OR MIT)", + 1 + ], + [ + "0BSD", + 1 + ], + [ + "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + 1 + ], + [ + "BlueOak-1.0.0", + 1 + ], + [ + "EPL-2.0", + 1 + ], + [ + "Python-2.0", + 1 + ], + [ + "Unlicense", + 1 + ], + [ + "Zlib", + 1 + ], + [ + "Zlib AND LicenseRef-Tinyland-Proprietary", + 1 + ] + ], + "runtimeLicenseHistogram": [ + [ + "MIT", + 225 + ], + [ + "Apache-2.0", + 106 + ], + [ + "BSD-3-Clause", + 11 + ], + [ + "ISC", + 10 + ], + [ + "OFL-1.1", + 4 + ], + [ + "(MPL-2.0 OR Apache-2.0)", + 1 + ], + [ + "0BSD", + 1 + ], + [ + "BSD-2-Clause", + 1 + ], + [ + "UNDECLARED", + 1 + ], + [ + "Zlib", + 1 + ] + ], + "undeclared": [ + "@better-fetch/fetch@1.1.21", + "khroma@2.1.0" + ], + "workspacePackages": [ + "@blog/agent", + "@blog/pulse-client", + "@blog/pulse-core" + ] + } +} diff --git a/src/routes/THIRD-PARTY-LICENSES/+page.svelte b/src/routes/THIRD-PARTY-LICENSES/+page.svelte index 9fd7a424..5e2f0e25 100644 --- a/src/routes/THIRD-PARTY-LICENSES/+page.svelte +++ b/src/routes/THIRD-PARTY-LICENSES/+page.svelte @@ -1,3 +1,19 @@ + + Third-Party Licenses | transscendsurvival.org @@ -10,87 +26,150 @@

This site is dedicated to the - public domain (CC0 1.0). - It is built with the following open-source software. + public domain (CC0 1.0). It is built with the following open-source software.

-

Runtime Dependencies

-
- - - - - - - - - - - - - - - - -
PackageVersionLicense
@skeletonlabs/skeleton5.0.0MIT
@skeletonlabs/skeleton-svelte5.0.0MIT
mdsvex0.12.6MIT
mermaid11.12.2MIT
shiki3.22.0MIT
tailwindcss4.1.18MIT
-
+

+ Every row below is derived from {inventory.source} — the declared range from + package.json, the resolved version, the license string and the source URL from the lockfile entry for + that exact resolution. Nothing on this page is hand-entered. A package whose lockfile entry declares no license + reads UNDECLARED and is never guessed. +

+ + {#each inventory.groups as group (group.key)} +

{group.heading}

+
+ + + + + + + + + + + + {#each group.rows as row (row.name)} + + + + + + + + {/each} + +
PackageDeclaredResolvedLicenseSource
+ {row.name} + {row.specifier}{row.version}{row.license} + {#if sourceLabel(row.resolved)} + {sourceLabel(row.resolved)} + {:else} + — + {/if} +
+
+ {/each} + +

Resolved Tree

+

+ The lockfile records {inventory.tree.installCount} installs under node_modules/. That counts install + paths, not packages — npm records a package once per place it lands. Deduplicated on name@version, + the tree is {inventory.tree.packageCount} distinct third-party packages, of which + {inventory.tree.runtimePackageCount} are reachable outside + devDependencies. +

-

Build Dependencies

+

Runtime packages

+

+ This is the set that governs attribution: the site is a prerendered static build, so only these licenses travel to + a reader. +

- - + - - - - - - - - - - - + {#each inventory.tree.runtimeLicenseHistogram as [license, count] (license)} + + + + + {/each}
PackageVersion LicensePackages
@sveltejs/adapter-static3.0.10MIT
@sveltejs/enhanced-img0.10.0MIT
@sveltejs/kit2.50.2MIT
@sveltejs/vite-plugin-svelte6.2.4MIT
@tailwindcss/vite4.1.18MIT
pagefind1.4.0MIT
sharp0.33.5Apache-2.0
svelte5.50.1MIT
svelte-check4.3.6MIT
typescript5.9.3Apache-2.0
vite6.4.1MIT
{license}{count}
-

Notable Transitive Dependencies

+

Full resolved tree

+

+ Development-only packages included. Licenses appearing here but not above are build-time only and are not + distributed. +

- - + - - - - - + {#each inventory.tree.licenseHistogram as [license, count] (license)} + + + + + {/each}
Package LicenseNotesPackages
chevrotainApache-2.0Parser toolkit (mermaid dep)
d3ISC / BSD-3-ClauseVisualization (mermaid dep)
dompurifyMPL-2.0 OR Apache-2.0HTML sanitizer (mermaid dep)
@iconify/utilsMITIcon utilities (skeleton dep)
@floating-ui/domMITPositioning runtime (Skeleton/Zag dep)
{license}{count}
-

License Summary

-

- All 243 production packages: MIT (190), ISC (34), - Apache-2.0 (8), BSD-3-Clause (6), - MPL-2.0 OR Apache-2.0 (1), Unlicense (1). -

+

Undeclared licenses

+ {#if inventory.tree.undeclared.length === 0} +

+ Every resolved third-party package declares a license. +

+ {:else} +

+ {inventory.tree.undeclared.length} resolved third-party package(s) declare no license field in the lockfile. Each + is an unreviewed license and a follow-up, not a silent MIT. +

+
    + {#each inventory.tree.undeclared as name (name)} +
  • {name}
  • + {/each} +
+ {/if} + + {#if inventory.tree.workspacePackages.length > 0} +

+ The lockfile also carries {inventory.tree.workspacePackages.length} workspace link(s) — + {inventory.tree.workspacePackages.join(', ')} — which are this repository's own code under its CC0 dedication. + They are not third party and are excluded from every figure above. +

+ {/if}

- Full machine-readable SBOM available at - THIRD-PARTY-LICENSES.md - in the source repository. + This page and + THIRD-PARTY-LICENSES.md + are generated from the same inventory by scripts/truth-surfaces.mjs, which fails if either drifts from + {inventory.source}.

diff --git a/vitest.bazel.config.ts b/vitest.bazel.config.ts index 5f73fd61..71d03e67 100644 --- a/vitest.bazel.config.ts +++ b/vitest.bazel.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ include: [ 'src/**/*.test.ts', 'scripts/gf-reapi-bazel-credential-helper.test.mts', + 'scripts/truth-surfaces.test.mts', 'scripts/wayback-utils.test.mts', 'packages/pulse-core/test/**/*.test.ts', 'packages/pulse-client/test/**/*.test.ts',