From 61ad6246c51da59384e62bedf135f862aa2281ca Mon Sep 17 00:00:00 2001 From: Jowitt13 <51783594+Jowitt13@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:16:54 +0800 Subject: [PATCH 1/6] feat(sbom): derive both SBOMs from esbuild metafile + reverse gate validate:sbom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 objective: prove that every third-party runtime package actually in skills/calculate-birth-charts/scripts/dist/engine.mjs is recorded in both CycloneDX and SPDX SBOMs, and that the SBOM source is the real esbuild bundle closure — not a hand-maintained list. Findings of note: The previous hand-authored SBOM listed 6 packages. The metafile-derived closure of the same bundle contains 10: astronomy-engine, dayjs, i18next, iztro, lunar-lite, lunar-typescript, moment, moment-timezone, tyme4ts, zod. Four packages (dayjs, i18next, lunar-lite, lunar-typescript) were shipping in engine.mjs but were absent from both SBOMs and from scan:licenses' cross-check. All four are MIT under docs/LICENSE_AUDIT.md policy; no policy violation, but a genuine supply-chain provenance gap that this P1 work closes. Changes: 1. tools/lib/bundle-closure.ts (new) Pure function computeBundleClosure(metafile, {root, readPackageJson?}). Classifies every esbuild metafile input into repo-internal (packages/*), Node built-in (node:*), virtual (), or third-party under node_modules. Walks each third-party path up to its package.json; verifies the directory tail is exactly `node_modules/` or `node_modules/@scope/` (so pnpm hash dirs never masquerade as packages). extractLicense() accepts SPDX-string, legacy license.type, and legacy licenses[] array forms; fail-closed on UNLICENSED / "SEE LICENSE IN " / missing. Same-name-different-version and same-root-inconsistent-metadata both throw. Output is deterministic: packages sorted by name, inputs sorted lexicographically. 2. tools/lib/bundle-closure.test.ts (new, 22 offline cases) Direct dep, transitive, scoped, pnpm-nested, pnpm-nested-scoped, repo-internal, node built-in, virtual, dedupe, version conflict, missing license, UNLICENSED, licenses[] OR expression, deterministic output, stray-package-json-outside-node_modules refusal, and a tmpdir end-to-end smoke via a real synthetic node_modules layout. No real repo file read or written. 3. tools/build-skill.ts Removed the 6-name resolveVersion() list and the hardcoded "MIT" array. After esbuild returns, compute the closure and drive both SBOMs from it. CycloneDX license field distinguishes SPDX id vs OR-expression form. SPDX 2.3 creationInfo.created remains pinned to 2026-01-01T00:00:00Z for byte reproducibility. Build fails closed if the closure derivation throws — no broken SBOM ever ships. 4. tools/validate-sbom.ts (new) Reverse gate: re-runs esbuild independently (write: false, metafile: true), recomputes the truth-set closure, then asserts every package appears in both SBOMs with matching name/version/purl/license; that neither SBOM carries a ghost third-party package not in the closure; that CycloneDX ↔ SPDX name+version+purl+license sets agree exactly; that the application component identity matches between formats; and that re-serializing each SBOM matches the on-disk bytes (catches hand-edits and non-deterministic writers). Optional tools/validate-sbom.exceptions.json permits documented, time-boxed exceptions with real-date expires and non-empty reason (currently absent). CLI supports --metafile/--sbom-cdx/--sbom-spdx injection for tests. 5. tools/validate-sbom.test.ts (new, 12 offline cases) Clean triple; closure has extra pkg SBOM missing it; SBOM has ghost pkg; CycloneDX vs SPDX version mismatch; license mismatch; purl mismatch; application-name drift; byte-instability; multi-input dedupe; documented exception permits; exception name matches but version differs (still fails); one-side-only pkg → set-mismatch. All inputs synthetic; no real SBOM read. 6. package.json + verify:cloud Adds "validate:sbom": "node tools/validate-sbom.ts" and inserts `pnpm run validate:sbom` between scan:licenses and scan:secrets in verify:cloud. 7. Chain listings + anti-drift guards .github/workflows/verify.yml top comment, README.md, AGENTS.md and the docs/VALIDATION.md table all list `validate:sbom` in the new position. tools/validate-current-docs.ts gains three new guards (workflow header, README chain, AGENTS chain must include validate:sbom); guards are exercised by three new isolated tests in tools/validate-current-docs.test.ts using the in-memory reader. validate:docs now runs 126/126 checks. 8. Doc prose docs/ARCHITECTURE.md: SBOM derivation via bundle-closure.ts and validate:sbom described in Build & packaging. docs/LICENSE_AUDIT.md: adds "Bundle-closure gate" bullet describing validate:sbom as the second half of the license enforcement cross-check. 9. Committed SBOM regeneration sbom.cdx.json / sbom.spdx.json rebuilt from the real closure (6 → 10 packages, all MIT). engine.mjs is byte-identical to the pre-P1 build. Confirmed via SHA-256: engine.mjs 8b26c7bca1e36474; sbom.cdx.json eb4b7bced3da42b1; sbom.spdx.json 7139883154cdde14 — same bytes across two consecutive `pnpm run build` runs. 10. Test count sync Real run 472/29 -> 509/31 (+22 bundle-closure tests, +12 validate-sbom tests, +3 validate-current-docs tests, +2 new test files). Synced to docs/STATUS.md + docs/VALIDATION.md via byte-level patch; surrounding legacy mojibake preserved. check:doc-counts 12/12. Local: pnpm vitest run tools/lib/bundle-closure.test.ts tools/validate-sbom.test.ts tools/validate-current-docs.test.ts -> 53/53. pnpm run test -> 509/31 all pass. pnpm run validate:sbom -> 86/86 pass. pnpm run verify:cloud -> green end-to-end (format:check, lint, typecheck, tests, build, validate:provenance, validate:skill 40/40, validate:reading 53/53, validate:docs 126/126, smoke, forward:test, hosts, verify:install 106/106, check:doc-counts 12/12, scan:deps LOCAL 0 advisories, scan:licenses 20/20, validate:sbom 86/86, scan:secrets clean over 223 files). pnpm run build x2 -> engine.mjs + both SBOMs byte-identical after each run (SHA-256 verified). git diff --check -> exit 0. Not executed (deliberately, per instructions): No push. No PR creation. No merge. No git tag or GitHub Release. No runtime dependency added. No lockfile change. No incident-token access. No algorithm/facts/schema/ruleset/interpretation-rule change. --- .github/workflows/verify.yml | 2 +- AGENTS.md | 2 +- README.md | 2 +- docs/ARCHITECTURE.md | 7 + docs/LICENSE_AUDIT.md | 5 + docs/STATUS.md | 2 +- docs/VALIDATION.md | 3 +- package.json | 3 +- skills/calculate-birth-charts/sbom.cdx.json | 82 +++- skills/calculate-birth-charts/sbom.spdx.json | 134 ++++-- tools/build-skill.ts | 156 +++---- tools/lib/bundle-closure.test.ts | 244 +++++++++++ tools/lib/bundle-closure.ts | 310 ++++++++++++++ tools/validate-current-docs.test.ts | 57 ++- tools/validate-current-docs.ts | 9 + tools/validate-sbom.test.ts | 297 ++++++++++++++ tools/validate-sbom.ts | 407 +++++++++++++++++++ 17 files changed, 1598 insertions(+), 124 deletions(-) create mode 100644 tools/lib/bundle-closure.test.ts create mode 100644 tools/lib/bundle-closure.ts create mode 100644 tools/validate-sbom.test.ts create mode 100644 tools/validate-sbom.ts diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 2dca6f0..e7746d4 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -6,7 +6,7 @@ name: verify # format:check -> lint -> typecheck -> test -> build -> validate:provenance -> # validate:skill -> validate:reading -> validate:docs -> smoke -> forward:test -> # package:hosts -> verify:hosts -> verify:install -> check:doc-counts -> -# scan:deps -> scan:licenses -> scan:secrets +# scan:deps -> scan:licenses -> validate:sbom -> scan:secrets # # The full local gate is `pnpm run verify:all = verify:cloud + scan:incident`. Its precise # incident tokens intentionally live only in a controlled local file and must never be copied to diff --git a/AGENTS.md b/AGENTS.md index bbe0fdd..07a28ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ pnpm run build # rebuild scripts/dist/engine.mjs + sbom.cdx.json (commit `verify:cloud` runs the reproducible, non-sensitive stages: `format:check → lint → typecheck → test → build → validate:provenance → validate:skill → validate:reading → validate:docs → smoke → forward:test → package:hosts → verify:hosts → verify:install → check:doc-counts → scan:deps → -scan:licenses → scan:secrets`. `verify:all` then adds `scan:incident`; its precise token file is ignored, must never +scan:licenses → validate:sbom → scan:secrets`. `verify:all` then adds `scan:incident`; its precise token file is ignored, must never enter CI, and its absence is intentionally fail-closed. If you change the test count, update `docs/STATUS.md` and `docs/VALIDATION.md` from a real run (never by hand). diff --git a/README.md b/README.md index 6d66344..6676c8f 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ pnpm run package # 生成 dist/*.zip + .sha256(自校验完整性) `verify:cloud` 依次运行:`format:check → lint → typecheck → test → build → validate:provenance → validate:skill → validate:reading → validate:docs → smoke → forward:test → package:hosts → -verify:hosts → verify:install → check:doc-counts → scan:deps → scan:licenses → scan:secrets`。`verify:all` 只在其后追加 +verify:hosts → verify:install → check:doc-counts → scan:deps → scan:licenses → validate:sbom → scan:secrets`。`verify:all` 只在其后追加 `scan:incident`,该扫描的私密 token 文件绝不进入 CI;缺失时必须 fail-closed。本仓库当前的真实测试计数由 GitHub Actions 的 `verify` job 与 [`docs/VALIDATION.md`](docs/VALIDATION.md) 的门禁段落负责同步(不再在 README 内维护一个易过期的静态数字)。 深入阅读:[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · [`docs/VALIDATION.md`](docs/VALIDATION.md) · diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e5ebc3f..51d68e0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -66,6 +66,13 @@ contracts <- time-location <- orchestrator -> engine-entry (esbuild) -> - its packed TZDB) into `scripts/dist/engine.mjs`, and writes `sbom.cdx.json`. The published Skill depends on neither `packages/` nor `node_modules`, and performs no install or network at runtime. +Both SBOMs (`sbom.cdx.json` CycloneDX and `sbom.spdx.json` SPDX 2.3) are derived from the esbuild +metafile's `inputs` list via `tools/lib/bundle-closure.ts` — there is no hand-maintained package +list. If a new third-party package ends up in the bundle, both SBOMs pick it up; if a package +disappears, both SBOMs drop it. `pnpm run validate:sbom` re-runs esbuild independently in +`verify:cloud` and requires an exact match (name/version/purl/license) between the fresh closure +and both committed SBOMs, fail-closed on any drift. + ## Time & location (the critical layer) Local civil time is normalized exactly once: parse wall clock → resolve against historical IANA diff --git a/docs/LICENSE_AUDIT.md b/docs/LICENSE_AUDIT.md index 0081f53..3657479 100644 --- a/docs/LICENSE_AUDIT.md +++ b/docs/LICENSE_AUDIT.md @@ -7,6 +7,11 @@ - **Enforced in the gate**: `pnpm run scan:licenses` (in `verify:cloud`) checks the whole production dependency closure against this policy offline and cross-checks the committed SBOM license claims; it fails closed. +- **Bundle-closure gate**: `pnpm run validate:sbom` (also in `verify:cloud`) re-runs esbuild, + derives the actual third-party runtime closure from the metafile, and requires both + `sbom.cdx.json` and `sbom.spdx.json` to record exactly that closure with matching + name/version/purl/license. The SBOM is derived from the metafile at build time — no + hand-maintained package list can drift from bundle reality. - This file is not legal advice; re-verify before any commercial release. ## Bundled into the published engine (`scripts/dist/engine.mjs`) diff --git a/docs/STATUS.md b/docs/STATUS.md index 0636464..dcd3d7c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -179,7 +179,7 @@ never by hand. | Command | Result | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pnpm run typecheck` | clean (tsc strict over packages, tools, tests) | -| `pnpm run test` | 472 tests / 29 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | +| `pnpm run test` | 509 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | | `pnpm run build` | `engine.mjs` ?2.8 MB + `sbom.cdx.json` + `sbom.spdx.json` (6 runtime deps) | | `pnpm run validate:skill` | 40 / 40 (incl. scripts/ no-stray-files guard + CycloneDX/SPDX SBOM checks + validate-answer/lint-reading gate-workflow doc checks) | | `pnpm run validate:reading` | 53 / 53 (topic example libraries + output-spec structure + 无术语区 firewall; offline, no LLM) | diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 2e9e954..44e9a2b 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -34,6 +34,7 @@ Before a release or a visibility change, run `pnpm run verify:all` and | Doc counts | `pnpm run check:doc-counts` | Re-runs the suite; fails if a doc's `N tests / M files` drifts from the run. | | Dep vuln scan | `pnpm run scan:deps` | Local: WARN + exit 0 if offline. CI: `DEPENDENCY_AUDIT_STRICT=1` fails closed on unreachable/parse. | | License scan | `pnpm run scan:licenses` | Offline `pnpm licenses` policy gate (LICENSE_AUDIT allowlist) + SBOM license cross-check; fail-closed. | +| SBOM validate | `pnpm run validate:sbom` | Fresh esbuild bundle closure vs both committed SBOMs; any drift/ghost/byte diff fails closed. | | Secret scan | `pnpm run scan:secrets` | Dependency-free scan of tracked files; fails on a leaked credential. | | Incident scan | local `pnpm run verify:all` | Exact incident tokens; fail-closed if the controlled token file is unavailable. | @@ -58,7 +59,7 @@ the identical table in [STATUS.md](./STATUS.md) ("Commands & results"). Do not h resolve a disagreement; re-run the suite and copy the actual count. `pnpm run check:doc-counts` re-runs the suite and fails if either doc's `N tests / M files` count drifts from the real run. -- Typecheck: clean. Tests: **472 tests / 29 files ?all passing**. The Western provider +- Typecheck: clean. Tests: **509 tests / 31 files ?all passing**. The Western provider (astronomy-engine, VSOP87 + NOVAS) passes the ADR-0003 ??gate two ways: wrapper-consistency (vs astronomy-engine's own output) plus an **independent JPL Horizons golden** (10 bodies × 3 technical epochs fetched from the NASA/JPL Horizons service, query recorded in diff --git a/package.json b/package.json index 182d59d..e3a0948 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "validate:reading": "node tools/validate-reading-examples.ts", "validate:docs": "node tools/validate-current-docs.ts", "validate:provenance": "node tools/validate-provenance.ts", + "validate:sbom": "node tools/validate-sbom.ts", "smoke": "node tools/smoke-clean-dir.ts", "forward:test": "node tools/forward-test.ts", "example": "node tools/gen-example.ts", @@ -37,7 +38,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint packages", - "verify:cloud": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && pnpm run validate:provenance && pnpm run validate:skill && pnpm run validate:reading && pnpm run validate:docs && pnpm run smoke && pnpm run forward:test && pnpm run package:hosts && pnpm run verify:hosts && pnpm run verify:install && pnpm run check:doc-counts && pnpm run scan:deps && pnpm run scan:licenses && pnpm run scan:secrets", + "verify:cloud": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && pnpm run validate:provenance && pnpm run validate:skill && pnpm run validate:reading && pnpm run validate:docs && pnpm run smoke && pnpm run forward:test && pnpm run package:hosts && pnpm run verify:hosts && pnpm run verify:install && pnpm run check:doc-counts && pnpm run scan:deps && pnpm run scan:licenses && pnpm run validate:sbom && pnpm run scan:secrets", "verify:all": "pnpm run verify:cloud && pnpm run scan:incident" }, "devDependencies": { diff --git a/skills/calculate-birth-charts/sbom.cdx.json b/skills/calculate-birth-charts/sbom.cdx.json index b8d8878..4b2ccc4 100644 --- a/skills/calculate-birth-charts/sbom.cdx.json +++ b/skills/calculate-birth-charts/sbom.cdx.json @@ -18,9 +18,9 @@ "components": [ { "type": "library", - "name": "zod", - "version": "4.4.3", - "purl": "pkg:npm/zod@4.4.3", + "name": "astronomy-engine", + "version": "2.1.19", + "purl": "pkg:npm/astronomy-engine@2.1.19", "licenses": [ { "license": { @@ -31,9 +31,61 @@ }, { "type": "library", - "name": "moment-timezone", - "version": "0.6.3", - "purl": "pkg:npm/moment-timezone@0.6.3", + "name": "dayjs", + "version": "1.11.21", + "purl": "pkg:npm/dayjs@1.11.21", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + { + "type": "library", + "name": "i18next", + "version": "23.16.8", + "purl": "pkg:npm/i18next@23.16.8", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + { + "type": "library", + "name": "iztro", + "version": "2.5.8", + "purl": "pkg:npm/iztro@2.5.8", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + { + "type": "library", + "name": "lunar-lite", + "version": "0.2.8", + "purl": "pkg:npm/lunar-lite@0.2.8", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + { + "type": "library", + "name": "lunar-typescript", + "version": "1.8.6", + "purl": "pkg:npm/lunar-typescript@1.8.6", "licenses": [ { "license": { @@ -57,9 +109,9 @@ }, { "type": "library", - "name": "tyme4ts", - "version": "1.5.2", - "purl": "pkg:npm/tyme4ts@1.5.2", + "name": "moment-timezone", + "version": "0.6.3", + "purl": "pkg:npm/moment-timezone@0.6.3", "licenses": [ { "license": { @@ -70,9 +122,9 @@ }, { "type": "library", - "name": "iztro", - "version": "2.5.8", - "purl": "pkg:npm/iztro@2.5.8", + "name": "tyme4ts", + "version": "1.5.2", + "purl": "pkg:npm/tyme4ts@1.5.2", "licenses": [ { "license": { @@ -83,9 +135,9 @@ }, { "type": "library", - "name": "astronomy-engine", - "version": "2.1.19", - "purl": "pkg:npm/astronomy-engine@2.1.19", + "name": "zod", + "version": "4.4.3", + "purl": "pkg:npm/zod@4.4.3", "licenses": [ { "license": { diff --git a/skills/calculate-birth-charts/sbom.spdx.json b/skills/calculate-birth-charts/sbom.spdx.json index 11e0d79..063d718 100644 --- a/skills/calculate-birth-charts/sbom.spdx.json +++ b/skills/calculate-birth-charts/sbom.spdx.json @@ -22,9 +22,9 @@ "licenseDeclared": "MIT" }, { - "name": "zod", - "SPDXID": "SPDXRef-Package-zod", - "versionInfo": "4.4.3", + "name": "astronomy-engine", + "SPDXID": "SPDXRef-Package-astronomy-engine", + "versionInfo": "2.1.19", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "MIT", @@ -33,14 +33,14 @@ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", - "referenceLocator": "pkg:npm/zod@4.4.3" + "referenceLocator": "pkg:npm/astronomy-engine@2.1.19" } ] }, { - "name": "moment-timezone", - "SPDXID": "SPDXRef-Package-moment-timezone", - "versionInfo": "0.6.3", + "name": "dayjs", + "SPDXID": "SPDXRef-Package-dayjs", + "versionInfo": "1.11.21", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "MIT", @@ -49,7 +49,71 @@ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", - "referenceLocator": "pkg:npm/moment-timezone@0.6.3" + "referenceLocator": "pkg:npm/dayjs@1.11.21" + } + ] + }, + { + "name": "i18next", + "SPDXID": "SPDXRef-Package-i18next", + "versionInfo": "23.16.8", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/i18next@23.16.8" + } + ] + }, + { + "name": "iztro", + "SPDXID": "SPDXRef-Package-iztro", + "versionInfo": "2.5.8", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/iztro@2.5.8" + } + ] + }, + { + "name": "lunar-lite", + "SPDXID": "SPDXRef-Package-lunar-lite", + "versionInfo": "0.2.8", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/lunar-lite@0.2.8" + } + ] + }, + { + "name": "lunar-typescript", + "SPDXID": "SPDXRef-Package-lunar-typescript", + "versionInfo": "1.8.6", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/lunar-typescript@1.8.6" } ] }, @@ -70,9 +134,9 @@ ] }, { - "name": "tyme4ts", - "SPDXID": "SPDXRef-Package-tyme4ts", - "versionInfo": "1.5.2", + "name": "moment-timezone", + "SPDXID": "SPDXRef-Package-moment-timezone", + "versionInfo": "0.6.3", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "MIT", @@ -81,14 +145,14 @@ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", - "referenceLocator": "pkg:npm/tyme4ts@1.5.2" + "referenceLocator": "pkg:npm/moment-timezone@0.6.3" } ] }, { - "name": "iztro", - "SPDXID": "SPDXRef-Package-iztro", - "versionInfo": "2.5.8", + "name": "tyme4ts", + "SPDXID": "SPDXRef-Package-tyme4ts", + "versionInfo": "1.5.2", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "MIT", @@ -97,14 +161,14 @@ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", - "referenceLocator": "pkg:npm/iztro@2.5.8" + "referenceLocator": "pkg:npm/tyme4ts@1.5.2" } ] }, { - "name": "astronomy-engine", - "SPDXID": "SPDXRef-Package-astronomy-engine", - "versionInfo": "2.1.19", + "name": "zod", + "SPDXID": "SPDXRef-Package-zod", + "versionInfo": "4.4.3", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "MIT", @@ -113,7 +177,7 @@ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", - "referenceLocator": "pkg:npm/astronomy-engine@2.1.19" + "referenceLocator": "pkg:npm/zod@4.4.3" } ] } @@ -127,12 +191,32 @@ { "spdxElementId": "SPDXRef-Package-calculate-birth-charts", "relationshipType": "DEPENDS_ON", - "relatedSpdxElement": "SPDXRef-Package-zod" + "relatedSpdxElement": "SPDXRef-Package-astronomy-engine" }, { "spdxElementId": "SPDXRef-Package-calculate-birth-charts", "relationshipType": "DEPENDS_ON", - "relatedSpdxElement": "SPDXRef-Package-moment-timezone" + "relatedSpdxElement": "SPDXRef-Package-dayjs" + }, + { + "spdxElementId": "SPDXRef-Package-calculate-birth-charts", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-i18next" + }, + { + "spdxElementId": "SPDXRef-Package-calculate-birth-charts", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-iztro" + }, + { + "spdxElementId": "SPDXRef-Package-calculate-birth-charts", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-lunar-lite" + }, + { + "spdxElementId": "SPDXRef-Package-calculate-birth-charts", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-lunar-typescript" }, { "spdxElementId": "SPDXRef-Package-calculate-birth-charts", @@ -142,17 +226,17 @@ { "spdxElementId": "SPDXRef-Package-calculate-birth-charts", "relationshipType": "DEPENDS_ON", - "relatedSpdxElement": "SPDXRef-Package-tyme4ts" + "relatedSpdxElement": "SPDXRef-Package-moment-timezone" }, { "spdxElementId": "SPDXRef-Package-calculate-birth-charts", "relationshipType": "DEPENDS_ON", - "relatedSpdxElement": "SPDXRef-Package-iztro" + "relatedSpdxElement": "SPDXRef-Package-tyme4ts" }, { "spdxElementId": "SPDXRef-Package-calculate-birth-charts", "relationshipType": "DEPENDS_ON", - "relatedSpdxElement": "SPDXRef-Package-astronomy-engine" + "relatedSpdxElement": "SPDXRef-Package-zod" } ] } diff --git a/tools/build-skill.ts b/tools/build-skill.ts index 2aa81ea..c39f451 100644 --- a/tools/build-skill.ts +++ b/tools/build-skill.ts @@ -1,15 +1,28 @@ import { build } from 'esbuild'; -import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { mkdirSync, statSync, writeFileSync } from 'node:fs'; +import { computeBundleClosure, type BundlePackage } from './lib/bundle-closure.ts'; /** * Bundle the deterministic engine into the Skill as a single self-contained ESM - * file (scripts/dist/engine.mjs) and emit a CycloneDX SBOM plus an SPDX 2.3 SBOM. - * The bundle inlines zod + moment-timezone (including its packed TZDB), so the - * published Skill has no dependency on the repo's packages/ or on `npm install` - * (handoff §3.1, §12). + * file (scripts/dist/engine.mjs) and emit a CycloneDX SBOM plus an SPDX 2.3 + * SBOM. The bundle inlines every runtime dependency into engine.mjs, so the + * published Skill has no dependency on the repo's packages/ or on + * `npm install` (handoff §3.1, §12). + * + * The two SBOMs are derived from the esbuild metafile's `inputs` list via + * `computeBundleClosure` (see tools/lib/bundle-closure.ts). There is no + * hand-maintained package list here — if a new third-party package ends up in + * the bundle, both SBOMs pick it up automatically; conversely, if a package + * disappears from the bundle, both SBOMs drop it. The closure derivation is + * fail-closed on missing / unresolvable license metadata, so an unaudited + * dependency cannot silently ship. + * + * Byte-stability: components and packages are sorted by name; the SPDX + * `created` timestamp is pinned to a fixed value so committed SBOM artifacts + * are byte-identical across rebuilds. `pnpm run validate:sbom` later verifies + * this end-to-end against a fresh esbuild pass. */ const here = dirname(fileURLToPath(import.meta.url)); @@ -18,21 +31,31 @@ const skillDir = join(root, 'skills', 'calculate-birth-charts'); const entry = join(root, 'packages', 'orchestrator', 'src', 'engine-entry.ts'); const outfile = join(skillDir, 'scripts', 'dist', 'engine.mjs'); -function resolveVersion(name: string, fromPkgJson: string): { version: string; dir: string } { - // Resolve the package entry point, then walk up to package.json (some packages - // don't export './package.json' in their "exports" map — e.g. tyme4ts). - const requireFrom = createRequire(fromPkgJson); - const entryPath = requireFrom.resolve(name); - // Walk up from the resolved entry to find the package root. - let current = dirname(entryPath); - while (!existsSync(join(current, 'package.json'))) { - const parent = dirname(current); - if (parent === current) throw new Error(`Cannot find package root for ${name}`); - current = parent; - } - const pkgPath = join(current, 'package.json'); - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }; - return { version: pkg.version, dir: current }; +const APP_NAME = 'calculate-birth-charts'; +const APP_VERSION = '0.1.0'; +// SPDX requires creationInfo.created; a wall-clock value would break the +// v0.1.2 byte-reproducibility of committed build artifacts, so this is a +// FIXED deterministic build timestamp (not the real build instant — the +// pinned dependency versions in `components` are the record). +const SPDX_FIXED_CREATED = '2026-01-01T00:00:00Z'; + +interface CycloneDxComponent { + type: 'library'; + name: string; + version: string; + purl: string; + licenses: unknown[]; +} + +/** + * Build the CycloneDX `licenses` array for one component from the closure's + * SPDX id or OR expression. + * - "MIT" -> [{ license: { id: "MIT" } }] + * - "(MIT OR Apache-2.0)" -> [{ expression: "(MIT OR Apache-2.0)" }] + */ +function buildCycloneDxLicenseField(spdx: string): unknown[] { + if (spdx.startsWith('(')) return [{ expression: spdx }]; + return [{ license: { id: spdx } }]; } async function main(): Promise { @@ -55,83 +78,70 @@ async function main(): Promise { const bytes = statSync(outfile).size; console.log(`engine.mjs bundled: ${(bytes / 1024).toFixed(1)} KiB`); - // --- CycloneDX SBOM from the actual bundled runtime dependencies --- - const timeLocPkg = join(root, 'packages', 'time-location', 'package.json'); - const baziPkg = join(root, 'packages', 'bazi', 'package.json'); - const ziweiPkg = join(root, 'packages', 'ziwei', 'package.json'); - const westernPkg = join(root, 'packages', 'western', 'package.json'); - const zod = resolveVersion('zod', timeLocPkg); - const momentTz = resolveVersion('moment-timezone', timeLocPkg); - const moment = resolveVersion('moment', join(momentTz.dir, 'package.json')); - const tyme = resolveVersion('tyme4ts', baziPkg); - const iztro = resolveVersion('iztro', ziweiPkg); - const astroEngine = resolveVersion('astronomy-engine', westernPkg); + // --- Derive the actual third-party runtime closure from esbuild metafile --- + const closure = computeBundleClosure(result.metafile, { root }); + console.log( + `bundle closure: ${closure.packages.length} third-party package(s); ` + + `ignored ${closure.ignored.repoInternal.length} repo-internal, ` + + `${closure.ignored.nodeBuiltin.length} node builtin, ` + + `${closure.ignored.virtual.length} virtual input(s).`, + ); - const components = [ - { name: 'zod', version: zod.version, license: 'MIT' }, - { name: 'moment-timezone', version: momentTz.version, license: 'MIT' }, - { name: 'moment', version: moment.version, license: 'MIT' }, - { name: 'tyme4ts', version: tyme.version, license: 'MIT' }, - { name: 'iztro', version: iztro.version, license: 'MIT' }, - { name: 'astronomy-engine', version: astroEngine.version, license: 'MIT' }, - ].map((c) => ({ + const components: CycloneDxComponent[] = closure.packages.map((p: BundlePackage) => ({ type: 'library', - name: c.name, - version: c.version, - purl: `pkg:npm/${c.name}@${c.version}`, - licenses: [{ license: { id: c.license } }], + name: p.name, + version: p.version, + purl: p.purl, + licenses: buildCycloneDxLicenseField(p.license), })); - const sbom = { + const sbomCdx = { bomFormat: 'CycloneDX', specVersion: '1.5', version: 1, metadata: { - // No wall-clock timestamp: the SBOM is a COMMITTED build artifact and must stay - // byte-stable across rebuilds/machines (v0.1.2 reproducibility). The pinned dependency - // versions below are the authoritative record. + // No wall-clock timestamp: the SBOM is a COMMITTED build artifact and + // must stay byte-stable across rebuilds/machines (v0.1.2 + // reproducibility). The pinned dependency versions below are the + // authoritative record. component: { type: 'application', - name: 'calculate-birth-charts', - version: '0.1.0', + name: APP_NAME, + version: APP_VERSION, }, tools: [{ name: 'ming-build-skill', version: '0.1.0' }], }, components, }; - writeFileSync(join(skillDir, 'sbom.cdx.json'), `${JSON.stringify(sbom, null, 2)}\n`, 'utf8'); + writeFileSync(join(skillDir, 'sbom.cdx.json'), `${JSON.stringify(sbomCdx, null, 2)}\n`, 'utf8'); console.log( - `sbom.cdx.json written (zod ${zod.version}, moment-timezone ${momentTz.version}, moment ${moment.version}, tyme4ts ${tyme.version}, iztro ${iztro.version}, astronomy-engine ${astroEngine.version})`, + `sbom.cdx.json written (${closure.packages.map((p) => `${p.name} ${p.version}`).join(', ')})`, ); - // --- SPDX 2.3 SBOM from the SAME component data (Phase 6: second standard format) --- - // SPDX requires creationInfo.created; a wall-clock value would break the v0.1.2 - // byte-reproducibility of committed build artifacts, so this is a FIXED deterministic - // build timestamp (not the real build instant — the pinned versions are the record). - const SPDX_FIXED_CREATED = '2026-01-01T00:00:00Z'; - const spdxPackages = components.map((c) => ({ - name: c.name, - SPDXID: `SPDXRef-Package-${c.name.replace(/[^A-Za-z0-9.-]/g, '-')}`, - versionInfo: c.version, + // --- SPDX 2.3 SBOM from the SAME closure (Phase 6: second standard format) --- + const spdxPackages = closure.packages.map((p) => ({ + name: p.name, + SPDXID: `SPDXRef-Package-${p.name.replace(/[^A-Za-z0-9.-]/g, '-')}`, + versionInfo: p.version, downloadLocation: 'NOASSERTION', filesAnalyzed: false, - licenseConcluded: c.licenses[0]!.license.id, - licenseDeclared: c.licenses[0]!.license.id, + licenseConcluded: p.license, + licenseDeclared: p.license, externalRefs: [ { referenceCategory: 'PACKAGE-MANAGER', referenceType: 'purl', - referenceLocator: c.purl, + referenceLocator: p.purl, }, ], })); - const spdx = { + const sbomSpdx = { spdxVersion: 'SPDX-2.3', dataLicense: 'CC0-1.0', SPDXID: 'SPDXRef-DOCUMENT', - name: 'calculate-birth-charts-0.1.0', - documentNamespace: 'https://github.com/Jowitt13/ming-engine/spdx/calculate-birth-charts-0.1.0', + name: `${APP_NAME}-${APP_VERSION}`, + documentNamespace: `https://github.com/Jowitt13/ming-engine/spdx/${APP_NAME}-${APP_VERSION}`, creationInfo: { created: SPDX_FIXED_CREATED, creators: ['Tool: ming-build-skill-0.1.0'], @@ -140,9 +150,9 @@ async function main(): Promise { }, packages: [ { - name: 'calculate-birth-charts', - SPDXID: 'SPDXRef-Package-calculate-birth-charts', - versionInfo: '0.1.0', + name: APP_NAME, + SPDXID: `SPDXRef-Package-${APP_NAME}`, + versionInfo: APP_VERSION, downloadLocation: 'NOASSERTION', filesAnalyzed: false, licenseConcluded: 'MIT', @@ -154,16 +164,16 @@ async function main(): Promise { { spdxElementId: 'SPDXRef-DOCUMENT', relationshipType: 'DESCRIBES', - relatedSpdxElement: 'SPDXRef-Package-calculate-birth-charts', + relatedSpdxElement: `SPDXRef-Package-${APP_NAME}`, }, ...spdxPackages.map((p) => ({ - spdxElementId: 'SPDXRef-Package-calculate-birth-charts', + spdxElementId: `SPDXRef-Package-${APP_NAME}`, relationshipType: 'DEPENDS_ON', relatedSpdxElement: p.SPDXID, })), ], }; - writeFileSync(join(skillDir, 'sbom.spdx.json'), `${JSON.stringify(spdx, null, 2)}\n`, 'utf8'); + writeFileSync(join(skillDir, 'sbom.spdx.json'), `${JSON.stringify(sbomSpdx, null, 2)}\n`, 'utf8'); console.log(`sbom.spdx.json written (SPDX 2.3, ${spdxPackages.length} dependency packages)`); const outputs = Object.keys(result.metafile.outputs); diff --git a/tools/lib/bundle-closure.test.ts b/tools/lib/bundle-closure.test.ts new file mode 100644 index 0000000..3ebab97 --- /dev/null +++ b/tools/lib/bundle-closure.test.ts @@ -0,0 +1,244 @@ +// Offline unit tests for tools/lib/bundle-closure.ts. +// Every test uses either an injected in-memory readPackageJson OR a tmpdir +// synthetic node_modules layout — never the real repo tree. +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, sep } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { computeBundleClosure, extractLicense } from './bundle-closure.ts'; + +/** + * In-memory readPackageJson factory: returns a function that answers + * package.json lookups for the given absolute-directory -> parsed JSON map. + * Any dir not present in the map returns null (equivalent to "no package.json"). + */ +function makeReader(map: Map): (dir: string) => unknown { + return (dir: string) => map.get(dir) ?? null; +} + +/** Build a virtual root path so tests stay OS-independent. */ +const ROOT = process.platform === 'win32' ? 'C:\\repo' : '/repo'; +const nm = (p: string): string => join(ROOT, ...p.split('/')); + +describe('bundle-closure: extractLicense', () => { + it('accepts modern SPDX string', () => { + expect(extractLicense({ license: 'MIT' })).toBe('MIT'); + }); + it('accepts legacy object license.type', () => { + expect(extractLicense({ license: { type: 'Apache-2.0' } })).toBe('Apache-2.0'); + }); + it('builds OR expression from legacy licenses[] array', () => { + expect( + extractLicense({ + licenses: [{ type: 'MIT' }, { type: 'Apache-2.0' }], + }), + ).toBe('(MIT OR Apache-2.0)'); + }); + it('rejects UNLICENSED', () => { + expect(() => extractLicense({ license: 'UNLICENSED' })).toThrow(/unresolvable license/); + }); + it('rejects "SEE LICENSE IN "', () => { + expect(() => extractLicense({ license: 'SEE LICENSE IN COPYING' })).toThrow( + /unresolvable license/, + ); + }); + it('rejects missing license field entirely', () => { + expect(() => extractLicense({})).toThrow(/unresolvable license/); + }); +}); + +describe('bundle-closure: computeBundleClosure', () => { + const dirZod = nm('node_modules/zod'); + const dirScoped = nm('node_modules/@scope/pkg'); + const dirPnpmFoo = nm('node_modules/.pnpm/foo@1.2.3/node_modules/foo'); + const dirPnpmScoped = nm('node_modules/.pnpm/@scope+bar@2.0.0/node_modules/@scope/bar'); + const dirMoment = nm('node_modules/moment'); + + const pkgMap = new Map([ + [dirZod, { name: 'zod', version: '4.4.3', license: 'MIT' }], + [dirScoped, { name: '@scope/pkg', version: '0.1.0', license: 'MIT' }], + [dirPnpmFoo, { name: 'foo', version: '1.2.3', license: 'MIT' }], + [dirPnpmScoped, { name: '@scope/bar', version: '2.0.0', license: 'MIT' }], + [dirMoment, { name: 'moment', version: '2.30.1', license: 'MIT' }], + ]); + const readPackageJson = makeReader(pkgMap); + + function run(paths: string[]) { + const inputs: Record = {}; + for (const p of paths) inputs[p] = {}; + return computeBundleClosure({ inputs }, { root: ROOT, readPackageJson }); + } + + it('1. direct dependency at node_modules/', () => { + const r = run(['node_modules/zod/index.js']); + expect(r.packages.map((p) => `${p.name}@${p.version}`)).toEqual(['zod@4.4.3']); + expect(r.packages[0]!.purl).toBe('pkg:npm/zod@4.4.3'); + expect(r.packages[0]!.license).toBe('MIT'); + }); + + it('2. transitive dependency alongside a direct one', () => { + const r = run(['node_modules/moment/moment.js', 'node_modules/zod/index.js']); + expect(r.packages.map((p) => p.name)).toEqual(['moment', 'zod']); + }); + + it('3. scoped package at node_modules/@scope/', () => { + const r = run(['node_modules/@scope/pkg/index.js']); + expect(r.packages[0]!.name).toBe('@scope/pkg'); + expect(r.packages[0]!.purl).toBe('pkg:npm/@scope/pkg@0.1.0'); + }); + + it('4. pnpm nested layout attributes to the real package, not the hash dir', () => { + const r = run([ + 'node_modules/.pnpm/foo@1.2.3/node_modules/foo/lib/a.js', + 'node_modules/.pnpm/foo@1.2.3/node_modules/foo/lib/b.js', + ]); + expect(r.packages.map((p) => `${p.name}@${p.version}`)).toEqual(['foo@1.2.3']); + expect(r.packages[0]!.inputs).toHaveLength(2); + }); + + it('5. pnpm nested + scoped', () => { + const r = run(['node_modules/.pnpm/@scope+bar@2.0.0/node_modules/@scope/bar/index.js']); + expect(r.packages[0]!.name).toBe('@scope/bar'); + expect(r.packages[0]!.version).toBe('2.0.0'); + }); + + it('6. repo-internal packages/ are ignored', () => { + const r = run(['packages/orchestrator/src/engine-entry.ts', 'node_modules/zod/index.js']); + expect(r.packages.map((p) => p.name)).toEqual(['zod']); + expect(r.ignored.repoInternal).toContain('packages/orchestrator/src/engine-entry.ts'); + }); + + it('7. Node built-in node:crypto is ignored', () => { + const r = run(['node:crypto', 'node_modules/zod/index.js']); + expect(r.ignored.nodeBuiltin).toEqual(['node:crypto']); + expect(r.packages.map((p) => p.name)).toEqual(['zod']); + }); + + it('8. esbuild synthetic input is ignored', () => { + const r = run(['', 'node_modules/zod/index.js']); + expect(r.ignored.virtual).toEqual(['']); + expect(r.packages.map((p) => p.name)).toEqual(['zod']); + }); + + it('9. multiple inputs for the same package deduplicate and sort inputs', () => { + const r = run(['node_modules/zod/z.js', 'node_modules/zod/a.js', 'node_modules/zod/m.js']); + expect(r.packages).toHaveLength(1); + expect(r.packages[0]!.inputs).toEqual([ + 'node_modules/zod/a.js', + 'node_modules/zod/m.js', + 'node_modules/zod/z.js', + ]); + }); + + it('10. same package name at two different roots with different versions -> throw', () => { + // Simulate a second `foo` package.json at a different pnpm hash dir. + const dirFooOther = nm('node_modules/.pnpm/foo@9.9.9/node_modules/foo'); + const map = new Map(pkgMap); + map.set(dirFooOther, { name: 'foo', version: '9.9.9', license: 'MIT' }); + expect(() => + computeBundleClosure( + { + inputs: { + 'node_modules/.pnpm/foo@1.2.3/node_modules/foo/a.js': {}, + 'node_modules/.pnpm/foo@9.9.9/node_modules/foo/b.js': {}, + }, + }, + { root: ROOT, readPackageJson: makeReader(map) }, + ), + ).toThrow(/multiple versions/); + }); + + it('11. missing license field -> throw', () => { + const dirBad = nm('node_modules/badpkg'); + const map = new Map([[dirBad, { name: 'badpkg', version: '1.0.0' }]]); + expect(() => + computeBundleClosure( + { inputs: { 'node_modules/badpkg/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ), + ).toThrow(/unresolvable license/); + }); + + it('12. license "UNLICENSED" -> throw', () => { + const dirBad = nm('node_modules/unl'); + const map = new Map([ + [dirBad, { name: 'unl', version: '1.0.0', license: 'UNLICENSED' }], + ]); + expect(() => + computeBundleClosure( + { inputs: { 'node_modules/unl/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ), + ).toThrow(/unresolvable license/); + }); + + it('13. legacy licenses[] array yields OR expression', () => { + const dirDual = nm('node_modules/dual'); + const map = new Map([ + [ + dirDual, + { + name: 'dual', + version: '3.0.0', + licenses: [{ type: 'MIT' }, { type: 'Apache-2.0' }], + }, + ], + ]); + const r = computeBundleClosure( + { inputs: { 'node_modules/dual/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ); + expect(r.packages[0]!.license).toBe('(MIT OR Apache-2.0)'); + }); + + it('14. output is deterministic: same metafile -> byte-identical JSON', () => { + const inputs = { + 'node_modules/zod/index.js': {}, + 'node_modules/moment/moment.js': {}, + 'node_modules/@scope/pkg/index.js': {}, + }; + const a = computeBundleClosure({ inputs }, { root: ROOT, readPackageJson }); + const b = computeBundleClosure({ inputs }, { root: ROOT, readPackageJson }); + expect(JSON.stringify(a.packages)).toBe(JSON.stringify(b.packages)); + }); + + it('15. package.json living outside node_modules -> throw (never attribute)', () => { + // Simulate a stray package.json in the repo root that a naive walker + // would trip on. The classifier must land the input into thirdParty (path + // contains node_modules) and refuse to attribute to a non-node_modules root. + const dirStray = nm('foo'); + const map = new Map([ + [dirStray, { name: 'foo', version: '0.0.0', license: 'MIT' }], + ]); + // Also provide a proper node_modules entry so the walk-up would otherwise + // pass; here we pass an input whose *only* package.json is at the stray dir. + expect(() => + computeBundleClosure( + { inputs: { 'node_modules/no-such-pkg/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ), + ).toThrow(/could not resolve package root/); + }); +}); + +describe('bundle-closure: real disk smoke via tmpdir', () => { + it('resolves against a real node_modules layout without touching the repo tree', () => { + const dir = mkdtempSync(join(tmpdir(), 'bc-')); + try { + const pkgDir = join(dir, 'node_modules', 'realfoo'); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify({ name: 'realfoo', version: '0.0.1', license: 'MIT' }), + ); + writeFileSync(join(pkgDir, 'index.js'), '// stub'); + const rel = ['node_modules', 'realfoo', 'index.js'].join('/'); + const inputs: Record = { [rel]: {} }; + const r = computeBundleClosure({ inputs }, { root: dir }); + expect(r.packages.map((p) => p.name + '@' + p.version)).toEqual(['realfoo@0.0.1']); + expect(r.packages[0]!.inputs[0]!.split(sep).join('/')).toBe(rel); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tools/lib/bundle-closure.ts b/tools/lib/bundle-closure.ts new file mode 100644 index 0000000..99be913 --- /dev/null +++ b/tools/lib/bundle-closure.ts @@ -0,0 +1,310 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, sep } from 'node:path'; + +/** + * Derive the third-party runtime dependency closure of a bundle from an esbuild + * metafile. The result is what `sbom.cdx.json` and `sbom.spdx.json` MUST + * describe: every third-party npm package whose source ended up in + * `scripts/dist/engine.mjs`. + * + * The function is pure over `(metafile, root, readPackageJson)` — no filesystem + * writes, no network. Callers inject `readPackageJson` in tests so nothing on + * disk is touched. The real build wires the default disk reader. + * + * Fail-closed policy: + * - Any third-party input path whose containing package cannot be resolved + * (missing package.json, missing `name` or `version`, ambiguous package + * layout, `.pnpm` hash directory misidentified as a package root, same + * name resolved to two different versions) causes a hard throw. + * - Any package with a license that cannot be resolved into an SPDX id or + * SPDX-`OR` expression (`UNLICENSED`, `SEE LICENSE IN LICENSE`, missing) + * causes a hard throw. + * - Path types we intentionally exclude are collected in `ignored` for + * observability but never silently discarded. + * + * The returned `packages` array is sorted by `name`; each package's `inputs` + * array is sorted lexicographically. Same metafile in → byte-identical + * `JSON.stringify(packages)` out. + */ + +export interface BundlePackage { + /** Full npm package name, including `@scope/` for scoped packages. */ + name: string; + /** Exact version read from the package's `package.json`. */ + version: string; + /** + * SPDX license id (`"MIT"`) or an OR expression built from a legacy + * `licenses` array (`"(MIT OR Apache-2.0)"`). Never `undefined`. + */ + license: string; + /** Package URL derived from `name` and `version`. */ + purl: string; + /** Absolute directory containing the package's `package.json`. */ + packageRoot: string; + /** + * All metafile input paths (relative to `root`) that resolved into this + * package, sorted lexicographically. Useful for diagnostics. + */ + inputs: string[]; +} + +export interface BundleClosureResult { + packages: BundlePackage[]; + ignored: { + repoInternal: string[]; + nodeBuiltin: string[]; + virtual: string[]; + }; +} + +export interface ComputeBundleClosureOptions { + root: string; + /** + * Read the package.json at the given absolute directory, returning its + * parsed JSON value or `null` if not present. Default: read from disk. + */ + readPackageJson?: (dir: string) => unknown; +} + +/** Parse a package.json value into a bare `{ name, version, license }` triple. */ +interface RawPackageJson { + name?: unknown; + version?: unknown; + license?: unknown; + licenses?: unknown; +} + +function defaultReadPackageJson(dir: string): unknown { + const p = join(dir, 'package.json'); + if (!existsSync(p)) return null; + try { + return JSON.parse(readFileSync(p, 'utf8')); + } catch { + return null; + } +} + +/** + * Extract an SPDX id or an SPDX-OR expression from a `package.json` license + * field. Fail-closed on anything unresolvable. + */ +export function extractLicense(pkg: RawPackageJson): string { + const bad = (why: string): never => { + throw new Error(`unresolvable license: ${why}`); + }; + + // Modern SPDX form. + if (typeof pkg.license === 'string') { + const v = pkg.license.trim(); + if (v === '' || /^UNLICENSED$/i.test(v) || /^SEE LICENSE IN /i.test(v)) { + bad(`license string "${pkg.license}"`); + } + return v; + } + // Legacy single-object form: { license: { type: "MIT" } }. + if ( + pkg.license !== null && + typeof pkg.license === 'object' && + typeof (pkg.license as { type?: unknown }).type === 'string' + ) { + const t = ((pkg.license as { type: string }).type ?? '').trim(); + if (t === '') bad('empty license.type'); + return t; + } + // Legacy array form: { licenses: [{ type: "MIT" }, { type: "Apache-2.0" }] }. + if (Array.isArray(pkg.licenses)) { + const ids: string[] = []; + for (const entry of pkg.licenses) { + if ( + entry !== null && + typeof entry === 'object' && + typeof (entry as { type?: unknown }).type === 'string' + ) { + const t = ((entry as { type: string }).type ?? '').trim(); + if (t === '') bad('empty licenses[i].type'); + ids.push(t); + } else { + bad('malformed licenses[] entry'); + } + } + if (ids.length === 0) bad('empty licenses[]'); + if (ids.length === 1) return ids[0]!; + return `(${ids.join(' OR ')})`; + } + bad('no license or licenses field'); + // Unreachable; `bad` throws. + return ''; +} + +/** + * Classify a metafile input path. + * + * The metafile paths esbuild emits are relative to the invocation cwd (in the + * ming build, that is the repo root). We only care about three exclusion + * categories plus everything else, which we treat as "third-party under + * node_modules and must be resolvable to a package root". + */ +type Category = + { kind: 'virtual' } | { kind: 'nodeBuiltin' } | { kind: 'repoInternal' } | { kind: 'thirdParty' }; + +function classifyInput(input: string): Category { + // esbuild emits synthetic entries like `` and ``. + if (input.startsWith('<') || input.includes('\x00')) return { kind: 'virtual' }; + if (input.startsWith('node:')) return { kind: 'nodeBuiltin' }; + // Normalise to forward slashes for consistent classification on Windows. + const norm = input.split(sep).join('/'); + if (norm.startsWith('packages/') || norm.includes('/packages/')) { + return { kind: 'repoInternal' }; + } + if (norm.startsWith('node_modules/') || norm.includes('/node_modules/')) { + return { kind: 'thirdParty' }; + } + // Anything else (a bare workspace-relative source that is neither packages/ + // nor node_modules) is likely a repo-internal file too; treat as internal so + // we never silently label it "unknown third-party". + return { kind: 'repoInternal' }; +} + +/** + * Walk up from a metafile input's absolute path, looking for the containing + * package.json. Returns the package root and its parsed JSON. Fail-closed if + * the path is not truly inside a valid npm package layout. + */ +function resolvePackageRoot( + inputAbs: string, + opts: { root: string; readPackageJson: (dir: string) => unknown }, +): { dir: string; json: RawPackageJson } { + let cur = dirname(inputAbs); + const stopAt = opts.root; + while (cur.length >= stopAt.length) { + const parsed = opts.readPackageJson(cur); + if (parsed !== null && parsed !== undefined && typeof parsed === 'object') { + const pkg = parsed as RawPackageJson; + if (typeof pkg.name === 'string' && typeof pkg.version === 'string') { + // Verify layout: the directory containing package.json must live + // directly under a node_modules dir (allowing `@scope/name`). + const rel = relative(opts.root, cur).split(sep).join('/'); + const parts = rel.split('/'); + const nmIdx = parts.lastIndexOf('node_modules'); + if (nmIdx < 0) { + throw new Error( + `package.json at ${rel} is not under node_modules; refusing to attribute input ${relative(opts.root, inputAbs)}`, + ); + } + const after = parts.slice(nmIdx + 1); + // Either ['name'] or ['@scope', 'name']. + const expected = + after.length === 1 + ? after[0] + : after.length === 2 && after[0]!.startsWith('@') + ? `${after[0]}/${after[1]}` + : null; + if (expected === null || expected !== pkg.name) { + throw new Error( + `package layout mismatch for input ${relative(opts.root, inputAbs)}: package.json name "${pkg.name}" but directory tail "${after.join('/')}"`, + ); + } + return { dir: cur, json: pkg }; + } + } + const next = dirname(cur); + if (next === cur) break; + cur = next; + } + throw new Error(`could not resolve package root for input ${relative(opts.root, inputAbs)}`); +} + +/** Sort a string array in place with a stable, locale-independent comparator. */ +function sortAscii(xs: string[]): string[] { + xs.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + return xs; +} + +export function computeBundleClosure( + metafile: { inputs: Record }, + opts: ComputeBundleClosureOptions, +): BundleClosureResult { + const readPackageJson = opts.readPackageJson ?? defaultReadPackageJson; + if (!isAbsolute(opts.root)) { + throw new Error(`root must be absolute, got ${opts.root}`); + } + + const ignored: BundleClosureResult['ignored'] = { + repoInternal: [], + nodeBuiltin: [], + virtual: [], + }; + /** Package root dir -> aggregated data. Keyed by absolute dir. */ + const byRoot = new Map(); + + const inputs = Object.keys(metafile.inputs); + for (const raw of inputs) { + const c = classifyInput(raw); + if (c.kind === 'virtual') { + ignored.virtual.push(raw); + continue; + } + if (c.kind === 'nodeBuiltin') { + ignored.nodeBuiltin.push(raw); + continue; + } + if (c.kind === 'repoInternal') { + ignored.repoInternal.push(raw); + continue; + } + // Third-party. Resolve to an absolute path relative to root, then walk up. + const abs = isAbsolute(raw) ? raw : join(opts.root, raw); + const { dir, json } = resolvePackageRoot(abs, { + root: opts.root, + readPackageJson, + }); + const license = extractLicense(json); + const name = json.name as string; + const version = json.version as string; + const existing = byRoot.get(dir); + if (existing) { + if (existing.version !== version || existing.license !== license) { + throw new Error( + `package ${name} resolved to conflicting metadata at ${dir}: ` + + `${existing.version}/${existing.license} vs ${version}/${license}`, + ); + } + existing.inputs.push(relative(opts.root, abs).split(sep).join('/')); + continue; + } + // Also fail-closed if a *different* dir maps to the *same* package name + // but a different version — we detect this later via the flattened list. + byRoot.set(dir, { + name, + version, + license, + purl: `pkg:npm/${name}@${version}`, + packageRoot: dir, + inputs: [relative(opts.root, abs).split(sep).join('/')], + }); + } + + // Detect name-collision-across-roots after aggregation. + const packages = [...byRoot.values()]; + const byName = new Map(); + for (const p of packages) { + const seen = byName.get(p.name); + if (seen && (seen.version !== p.version || seen.license !== p.license)) { + throw new Error( + `package ${p.name} resolved to multiple versions in the bundle: ` + + `${seen.version}/${seen.license} at ${seen.packageRoot} and ` + + `${p.version}/${p.license} at ${p.packageRoot}`, + ); + } + if (!seen) byName.set(p.name, p); + } + + for (const p of packages) sortAscii(p.inputs); + packages.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + + sortAscii(ignored.repoInternal); + sortAscii(ignored.nodeBuiltin); + sortAscii(ignored.virtual); + + return { packages, ignored }; +} diff --git a/tools/validate-current-docs.test.ts b/tools/validate-current-docs.test.ts index 6519cd3..212088a 100644 --- a/tools/validate-current-docs.test.ts +++ b/tools/validate-current-docs.test.ts @@ -28,7 +28,7 @@ function baseFixture(): Map { '', '需要脚本执行 能力。', '', - 'verify:cloud 依次运行 scan:deps → scan:licenses → scan:secrets.', + 'verify:cloud 依次运行 scan:deps → scan:licenses → validate:sbom → scan:secrets.', '', '指向 GitHub Release `v0.1.6`.', ].join('\n'), @@ -100,7 +100,7 @@ function baseFixture(): Map { files.set( 'AGENTS.md', - 'render disabled exit 3. verify:cloud runs scan:deps → scan:licenses → scan:secrets.', + 'render disabled exit 3. verify:cloud runs scan:deps → scan:licenses → validate:sbom → scan:secrets.', ); files.set('docs/PRODUCT_SPEC.md', 'JSON output only.'); @@ -130,14 +130,15 @@ function baseFixture(): Map { ); files.set('docs/installers/doubao.md', 'doubao installer'); - // Workflow with the accurate full chain including validate:provenance and scan:licenses. + // Workflow with the accurate full chain including validate:provenance, + // scan:licenses and validate:sbom. files.set( '.github/workflows/verify.yml', [ '# format:check -> lint -> typecheck -> test -> build -> validate:provenance ->', '# validate:skill -> validate:reading -> validate:docs -> smoke -> forward:test ->', '# package:hosts -> verify:hosts -> verify:install -> check:doc-counts ->', - '# scan:deps -> scan:licenses -> scan:secrets', + '# scan:deps -> scan:licenses -> validate:sbom -> scan:secrets', 'env:', " DEPENDENCY_AUDIT_STRICT: '1'", ].join('\n'), @@ -238,7 +239,10 @@ describe('validate-current-docs: injected reader', () => { const cur = files.get('README.md') as string; files.set( 'README.md', - cur.replace('scan:deps → scan:licenses → scan:secrets', 'scan:deps → scan:secrets'), + cur.replace( + 'scan:deps → scan:licenses → validate:sbom → scan:secrets', + 'scan:deps → scan:secrets', + ), ); const { failed } = runChecks(readerOf(files)); expect(failed.map((f) => f.name)).toContain( @@ -347,6 +351,49 @@ describe('validate-current-docs: injected reader', () => { ); }); + it('16. workflow header missing validate:sbom -> matching FAIL (new guard)', () => { + const files = baseFixture(); + files.set( + '.github/workflows/verify.yml', + [ + '# format:check -> ... -> validate:provenance -> ... -> scan:licenses -> scan:secrets', + "env:\n DEPENDENCY_AUDIT_STRICT: '1'", + ].join('\n'), + ); + const { failed } = runChecks(readerOf(files)); + expect(failed.map((f) => f.name)).toContain( + 'workflow: header comment chain lists validate:sbom', + ); + }); + + it('17. README verify:cloud chain missing validate:sbom -> matching FAIL (new guard)', () => { + const files = baseFixture(); + const cur = files.get('README.md') as string; + files.set( + 'README.md', + cur.replace( + 'scan:deps → scan:licenses → validate:sbom → scan:secrets', + 'scan:deps → scan:licenses → scan:secrets', + ), + ); + const { failed } = runChecks(readerOf(files)); + expect(failed.map((f) => f.name)).toContain( + 'README.md: verify:cloud chain includes validate:sbom', + ); + }); + + it('18. AGENTS.md verify:cloud chain missing validate:sbom -> matching FAIL (new guard)', () => { + const files = baseFixture(); + files.set( + 'AGENTS.md', + 'render disabled exit 3. verify:cloud runs scan:deps → scan:licenses → scan:secrets.', + ); + const { failed } = runChecks(readerOf(files)); + expect(failed.map((f) => f.name)).toContain( + 'AGENTS.md: verify:cloud chain includes validate:sbom', + ); + }); + it('14. README contains "tests-N passing" shield badge -> matching FAIL (new guard)', () => { const files = baseFixture(); const cur = files.get('README.md') as string; diff --git a/tools/validate-current-docs.ts b/tools/validate-current-docs.ts index b981daf..853dec1 100644 --- a/tools/validate-current-docs.ts +++ b/tools/validate-current-docs.ts @@ -429,6 +429,7 @@ export function runChecks(readDoc: DocReader = defaultReadDoc): { /validate:provenance/.test(workflow), ); add('workflow: header comment chain lists scan:licenses', /scan:licenses/.test(workflow)); + add('workflow: header comment chain lists validate:sbom', /validate:sbom/.test(workflow)); } const validationMd = readDoc('docs/VALIDATION.md'); if (validationMd) { @@ -459,6 +460,10 @@ export function runChecks(readDoc: DocReader = defaultReadDoc): { 'README.md: verify:cloud chain includes scan:licenses', /scan:deps[\s\S]*scan:licenses[\s\S]*scan:secrets/.test(readmeMd), ); + add( + 'README.md: verify:cloud chain includes validate:sbom', + /scan:licenses[\s\S]*validate:sbom[\s\S]*scan:secrets/.test(readmeMd), + ); // Static test counts drift the moment the suite grows; the real count lives // on GitHub Actions `verify` + docs/VALIDATION.md, not in README. Match both // bold Markdown form (`**471 tests / 29 files**`) and plain text form @@ -478,6 +483,10 @@ export function runChecks(readDoc: DocReader = defaultReadDoc): { 'AGENTS.md: verify:cloud chain includes scan:licenses', /scan:deps[\s\S]*scan:licenses[\s\S]*scan:secrets/.test(agentsMd), ); + add( + 'AGENTS.md: verify:cloud chain includes validate:sbom', + /scan:licenses[\s\S]*validate:sbom[\s\S]*scan:secrets/.test(agentsMd), + ); } // scan-deps.ts must not promise that CI has network. The fail-closed guarantee // is DEPENDENCY_AUDIT_STRICT=1, not an implicit assumption about network reach. diff --git a/tools/validate-sbom.test.ts b/tools/validate-sbom.test.ts new file mode 100644 index 0000000..ae1792d --- /dev/null +++ b/tools/validate-sbom.test.ts @@ -0,0 +1,297 @@ +// Offline tests for tools/validate-sbom.ts. +// Every case builds synthetic `closure` + synthetic SBOM strings entirely in +// memory. Nothing on disk is read or written; no real SBOM is touched. +import { describe, expect, it } from 'vitest'; +import { validateSbom, type ValidateSbomInputs } from './validate-sbom.ts'; +import type { BundlePackage } from './lib/bundle-closure.ts'; + +/** Build a canonical BundlePackage; individual tests can override fields. */ +function pkg(name: string, version: string, license = 'MIT'): BundlePackage { + return { + name, + version, + license, + purl: `pkg:npm/${name}@${version}`, + packageRoot: `/fake/${name}`, + inputs: [`node_modules/${name}/index.js`], + }; +} + +const APP_NAME = 'calculate-birth-charts'; +const APP_VERSION = '0.1.0'; + +function cdx( + components: { + name: string; + version: string; + purl?: string; + license?: string; + expression?: string; + type?: string; + }[], +) { + return { + bomFormat: 'CycloneDX', + specVersion: '1.5', + version: 1, + metadata: { + component: { type: 'application', name: APP_NAME, version: APP_VERSION }, + tools: [{ name: 'ming-build-skill', version: '0.1.0' }], + }, + components: components.map((c) => ({ + type: c.type ?? 'library', + name: c.name, + version: c.version, + purl: c.purl ?? `pkg:npm/${c.name}@${c.version}`, + licenses: c.expression + ? [{ expression: c.expression }] + : [{ license: { id: c.license ?? 'MIT' } }], + })), + }; +} + +function spdx(components: { name: string; version: string; purl?: string; license?: string }[]) { + const spdxPackages = components.map((c) => ({ + name: c.name, + SPDXID: `SPDXRef-Package-${c.name.replace(/[^A-Za-z0-9.-]/g, '-')}`, + versionInfo: c.version, + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: c.license ?? 'MIT', + licenseDeclared: c.license ?? 'MIT', + externalRefs: [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: c.purl ?? `pkg:npm/${c.name}@${c.version}`, + }, + ], + })); + return { + spdxVersion: 'SPDX-2.3', + dataLicense: 'CC0-1.0', + SPDXID: 'SPDXRef-DOCUMENT', + name: `${APP_NAME}-${APP_VERSION}`, + documentNamespace: `https://github.com/Jowitt13/ming-engine/spdx/${APP_NAME}-${APP_VERSION}`, + creationInfo: { + created: '2026-01-01T00:00:00Z', + creators: ['Tool: ming-build-skill-0.1.0'], + comment: 'test fixture', + }, + packages: [ + { + name: APP_NAME, + SPDXID: `SPDXRef-Package-${APP_NAME}`, + versionInfo: APP_VERSION, + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: 'MIT', + licenseDeclared: 'MIT', + }, + ...spdxPackages, + ], + relationships: [ + { + spdxElementId: 'SPDXRef-DOCUMENT', + relationshipType: 'DESCRIBES', + relatedSpdxElement: `SPDXRef-Package-${APP_NAME}`, + }, + ...spdxPackages.map((p) => ({ + spdxElementId: `SPDXRef-Package-${APP_NAME}`, + relationshipType: 'DEPENDS_ON', + relatedSpdxElement: p.SPDXID, + })), + ], + }; +} + +/** Serialize as the build tool does: `JSON.stringify(x, null, 2) + '\n'`. */ +function ser(obj: unknown): string { + return `${JSON.stringify(obj, null, 2)}\n`; +} + +/** Build a valid triple (closure + matching CycloneDX + matching SPDX). */ +function goodTriple(): ValidateSbomInputs { + const closure = [pkg('zod', '4.4.3'), pkg('moment', '2.30.1')]; + const cdxObj = cdx(closure.map((p) => ({ name: p.name, version: p.version }))); + const spdxObj = spdx(closure.map((p) => ({ name: p.name, version: p.version }))); + return { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/sbom.cdx.json', + spdxPath: '/mem/sbom.spdx.json', + }; +} + +function failedNames(input: ValidateSbomInputs): string[] { + return validateSbom(input) + .filter((c) => !c.ok) + .map((c) => c.name); +} + +describe('validate-sbom (offline synthetic)', () => { + it('1. clean triple -> zero failures', () => { + expect(failedNames(goodTriple())).toEqual([]); + }); + + it('2. metafile has an extra package that SBOM does not list -> FAIL both cyclonedx and spdx has X', () => { + const t = goodTriple(); + t.closure.push(pkg('astronomy-engine', '2.1.19')); + const failed = failedNames(t); + expect(failed).toContain('cyclonedx has astronomy-engine'); + expect(failed).toContain('spdx has astronomy-engine'); + }); + + it('3. SBOMs list a ghost package not in closure -> FAIL', () => { + const t = goodTriple(); + const cdxObj = cdx([ + ...t.closure.map((p) => ({ name: p.name, version: p.version })), + { name: 'ghost', version: '1.0.0' }, + ]); + const spdxObj = spdx([ + ...t.closure.map((p) => ({ name: p.name, version: p.version })), + { name: 'ghost', version: '1.0.0' }, + ]); + t.cdxText = ser(cdxObj); + t.spdxText = ser(spdxObj); + const failed = failedNames(t); + expect(failed).toContain('cyclonedx has no ghost package: ghost'); + expect(failed).toContain('spdx has no ghost package: ghost'); + }); + + it('4. CycloneDX version differs from SPDX for the same package -> FAIL', () => { + const t = goodTriple(); + const cdxObj = cdx([ + { name: 'zod', version: '4.4.3' }, + { name: 'moment', version: '2.30.1' }, + ]); + const spdxObj = spdx([ + { name: 'zod', version: '4.4.99' }, + { name: 'moment', version: '2.30.1' }, + ]); + t.cdxText = ser(cdxObj); + t.spdxText = ser(spdxObj); + const failed = failedNames(t); + expect(failed).toContain('spdx version matches: zod'); + expect(failed).toContain('cyclonedx set equals spdx set'); + }); + + it('5. license mismatch vs closure -> FAIL both formats', () => { + const t = goodTriple(); + const cdxObj = cdx([ + { name: 'zod', version: '4.4.3', license: 'Apache-2.0' }, + { name: 'moment', version: '2.30.1' }, + ]); + t.cdxText = ser(cdxObj); + const failed = failedNames(t); + expect(failed).toContain('cyclonedx license matches: zod'); + expect(failed).toContain('cyclonedx set equals spdx set'); + }); + + it('6. purl not equal to name@version -> FAIL', () => { + const t = goodTriple(); + const cdxObj = cdx([ + { name: 'zod', version: '4.4.3', purl: 'pkg:npm/zod@9.9.9' }, + { name: 'moment', version: '2.30.1' }, + ]); + t.cdxText = ser(cdxObj); + const failed = failedNames(t); + expect(failed).toContain('cyclonedx purl matches: zod'); + }); + + it('7. application component name drift -> FAIL', () => { + const t = goodTriple(); + const cdxObj: { + metadata?: { component?: { name?: string; version?: string } }; + } = JSON.parse(t.cdxText); + if (cdxObj.metadata?.component) cdxObj.metadata.component.name = 'other-app'; + t.cdxText = ser(cdxObj); + const failed = failedNames(t); + expect(failed).toContain(`cyclonedx application component is ${APP_NAME}`); + }); + + it('8. byte-instability: extra whitespace on disk -> FAIL byte-stable check', () => { + const t = goodTriple(); + // Prepend a whitespace character so the disk bytes do not match a re-serialization. + t.cdxText = '\n' + t.cdxText; + const failed = failedNames(t); + expect(failed).toContain('cyclonedx SBOM is byte-stable (reserialization matches disk)'); + }); + + it('9. same closure package present via multiple inputs -> not double-counted', () => { + const t = goodTriple(); + // Duplicate the closure entry as if two different metafile inputs mapped + // to it. The dedupe happens upstream in computeBundleClosure; here we + // simply assert validate-sbom does not emit spurious extra "has X" FAILs. + // For robustness we also add a second copy with a different `packageRoot` + // but the same name/version/license (which validate-sbom is agnostic to). + const dup = { ...pkg('zod', '4.4.3'), packageRoot: '/fake/zod-dup' }; + t.closure.push(dup); + const failed = failedNames(t); + // Validate-sbom builds a Map by name; the duplicate is idempotent. + expect(failed.filter((n) => n.startsWith('cyclonedx has zod'))).toEqual([]); + expect(failed.filter((n) => n.startsWith('spdx has zod'))).toEqual([]); + }); + + it('10. documented exception permits a non-closure package with matching version', () => { + const t = goodTriple(); + // Both SBOMs list a package that is not in the closure but the exceptions + // file permits it explicitly. The gate should pass. + const cdxObj = cdx([ + ...t.closure.map((p) => ({ name: p.name, version: p.version })), + { name: 'legacy-tool', version: '0.0.1' }, + ]); + const spdxObj = spdx([ + ...t.closure.map((p) => ({ name: p.name, version: p.version })), + { name: 'legacy-tool', version: '0.0.1' }, + ]); + t.cdxText = ser(cdxObj); + t.spdxText = ser(spdxObj); + t.exceptions = [ + { + name: 'legacy-tool', + version: '0.0.1', + reason: 'documented ship of hand-audited fixture', + expires: '2099-12-31', + }, + ]; + const failed = failedNames(t); + // 'has no ghost' checks turn into 'extra allowed' PASSes. + expect(failed).not.toContain('cyclonedx has no ghost package: legacy-tool'); + expect(failed).not.toContain('spdx has no ghost package: legacy-tool'); + }); + + it('10b. exception name matches but version differs -> FAIL (still catches drift)', () => { + const t = goodTriple(); + const cdxObj = cdx([ + ...t.closure.map((p) => ({ name: p.name, version: p.version })), + { name: 'legacy-tool', version: '0.0.2' }, + ]); + const spdxObj = spdx([ + ...t.closure.map((p) => ({ name: p.name, version: p.version })), + { name: 'legacy-tool', version: '0.0.2' }, + ]); + t.cdxText = ser(cdxObj); + t.spdxText = ser(spdxObj); + t.exceptions = [ + { name: 'legacy-tool', version: '0.0.1', reason: 'doc', expires: '2099-12-31' }, + ]; + const failed = failedNames(t); + expect(failed).toContain('cyclonedx extra allowed: legacy-tool@0.0.2'); + expect(failed).toContain('spdx extra allowed: legacy-tool@0.0.2'); + }); + + it('11. only CycloneDX has a package (SPDX missing it) -> set-mismatch FAIL', () => { + const t = goodTriple(); + const cdxObj = cdx(t.closure.map((p) => ({ name: p.name, version: p.version }))); + // Remove `moment` from SPDX. + const spdxObj = spdx([{ name: 'zod', version: '4.4.3' }]); + t.cdxText = ser(cdxObj); + t.spdxText = ser(spdxObj); + const failed = failedNames(t); + expect(failed).toContain('spdx has moment'); + expect(failed).toContain('cyclonedx set equals spdx set'); + }); +}); diff --git a/tools/validate-sbom.ts b/tools/validate-sbom.ts new file mode 100644 index 0000000..5390551 --- /dev/null +++ b/tools/validate-sbom.ts @@ -0,0 +1,407 @@ +import { build } from 'esbuild'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { computeBundleClosure, type BundlePackage } from './lib/bundle-closure.ts'; + +/** + * Reverse gate for the CycloneDX + SPDX SBOMs shipped with the Skill. + * + * The `build` step derives both SBOMs from the esbuild metafile via + * `computeBundleClosure`. This tool runs a fresh esbuild pass, recomputes the + * closure independently, and asserts that BOTH committed SBOMs describe + * EXACTLY that closure: no missing packages, no extra packages, per-package + * name/version/purl/license match, and the two SBOMs match each other. + * + * It also re-serializes each SBOM and requires the result to be byte-identical + * to disk, so a hand-edit that breaks reproducibility is caught here too. + * + * Flags (used by tests): + * --metafile read a captured esbuild metafile JSON instead of + * spawning a fresh build. Purely for offline unit tests. + * --root override the repo root (default: parent of this file). + * --sbom-cdx path to CycloneDX SBOM (default: + * skills/calculate-birth-charts/sbom.cdx.json). + * --sbom-spdx path to SPDX SBOM (default: + * skills/calculate-birth-charts/sbom.spdx.json). + * + * There is a documented exceptions file (tools/validate-sbom.exceptions.json) + * — currently absent; if ever present it must be a JSON array of + * { name, version, reason, expires: YYYY-MM-DD } and the expires date must be + * a real calendar date at or after today (fail-closed otherwise). + */ + +const here = dirname(fileURLToPath(import.meta.url)); + +// --- Arg parsing -------------------------------------------------------------- +const argv = process.argv.slice(2); +const getFlag = (name: string): string | undefined => { + const i = argv.indexOf(name); + return i >= 0 ? argv[i + 1] : undefined; +}; +const rootArg = getFlag('--root'); +const root = rootArg + ? rootArg.startsWith('.') + ? join(process.cwd(), rootArg) + : rootArg + : join(here, '..'); +const metafileArg = getFlag('--metafile'); +const sbomCdxArg = getFlag('--sbom-cdx'); +const sbomSpdxArg = getFlag('--sbom-spdx'); +const APP_NAME = 'calculate-birth-charts'; + +interface Check { + name: string; + ok: boolean; + detail?: string; +} + +/** Real-date check identical to scan-deps.ts's isRealDate. */ +function isRealDate(s: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false; + const [y, m, d] = s.split('-').map(Number); + const dt = new Date(Date.UTC(y!, (m ?? 1) - 1, d!)); + return dt.getUTCFullYear() === y && dt.getUTCMonth() === (m ?? 1) - 1 && dt.getUTCDate() === d; +} + +interface Exception { + name: string; + version: string; + reason: string; + expires: string; +} +function loadExceptions(): Exception[] { + const p = join(root, 'tools', 'validate-sbom.exceptions.json'); + if (!existsSync(p)) return []; + const raw = JSON.parse(readFileSync(p, 'utf8')); + if (!Array.isArray(raw)) throw new Error(`${p}: exceptions must be a JSON array`); + const today = new Date().toISOString().slice(0, 10); + const seen = new Set(); + for (const [i, e] of raw.entries()) { + if (typeof e !== 'object' || e === null || Array.isArray(e)) { + throw new Error(`${p}: exceptions[${i}] is not an object`); + } + const en = e as Record; + if (typeof en.name !== 'string' || en.name.trim().length === 0) + throw new Error(`${p}: exceptions[${i}].name missing`); + if (typeof en.version !== 'string' || en.version.trim().length === 0) + throw new Error(`${p}: exceptions[${i}].version missing`); + if (typeof en.reason !== 'string' || en.reason.trim().length === 0) + throw new Error(`${p}: exceptions[${i}].reason missing/empty`); + if (typeof en.expires !== 'string' || !isRealDate(en.expires)) + throw new Error(`${p}: exceptions[${i}].expires not a real YYYY-MM-DD date`); + if (en.expires < today) + throw new Error(`${p}: exceptions[${i}] "${en.name}@${en.version}" expired on ${en.expires}`); + const key = `${en.name}@${en.version}`; + if (seen.has(key)) throw new Error(`${p}: duplicate exception ${key}`); + seen.add(key); + } + return raw as Exception[]; +} + +// --- SBOM shapes we consume -------------------------------------------------- +interface CdxLicense { + license?: { id?: string }; + expression?: string; +} +interface CdxComponent { + type?: string; + name?: string; + version?: string; + purl?: string; + licenses?: CdxLicense[]; +} +interface CdxSbom { + metadata?: { component?: { name?: string; version?: string } }; + components?: CdxComponent[]; +} + +interface SpdxPackage { + name?: string; + SPDXID?: string; + versionInfo?: string; + licenseConcluded?: string; + licenseDeclared?: string; + externalRefs?: { referenceType?: string; referenceLocator?: string }[]; +} +interface SpdxSbom { + packages?: SpdxPackage[]; +} + +/** Read a CycloneDX SBOM and normalise the third-party components. */ +function extractCdxPackages( + sbom: CdxSbom, +): Map { + const out = new Map(); + for (const c of sbom.components ?? []) { + if (c.type !== 'library') continue; + if (typeof c.name !== 'string' || typeof c.version !== 'string' || typeof c.purl !== 'string') + continue; + const lic = c.licenses?.[0]; + const license = lic?.expression ?? lic?.license?.id ?? ''; + out.set(c.name, { version: c.version, purl: c.purl, license }); + } + return out; +} + +/** Read an SPDX SBOM and normalise the third-party packages (excluding the application). */ +function extractSpdxPackages( + sbom: SpdxSbom, +): Map { + const out = new Map(); + for (const p of sbom.packages ?? []) { + if (typeof p.name !== 'string') continue; + if (p.name === APP_NAME) continue; + const purl = p.externalRefs?.find((r) => r.referenceType === 'purl')?.referenceLocator ?? ''; + out.set(p.name, { + version: p.versionInfo ?? '', + purl, + license: p.licenseConcluded ?? p.licenseDeclared ?? '', + }); + } + return out; +} + +// --- Closure source ---------------------------------------------------------- +async function computeTruthClosure(): Promise { + let metafile: { inputs: Record }; + if (metafileArg) { + const p = join(root, metafileArg); + metafile = JSON.parse(readFileSync(p, 'utf8')) as { + inputs: Record; + }; + } else { + const result = await build({ + entryPoints: [join(root, 'packages', 'orchestrator', 'src', 'engine-entry.ts')], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node22', + write: false, + metafile: true, + logLevel: 'silent', + }); + metafile = result.metafile; + } + return computeBundleClosure(metafile, { root }).packages; +} + +/** + * Re-serialize a JSON object with the exact same formatting the build tool + * uses (`JSON.stringify(x, null, 2) + '\n'`), so byte-comparison against the + * on-disk file catches hand edits or non-deterministic tooling. + */ +function reserialize(obj: unknown): string { + return `${JSON.stringify(obj, null, 2)}\n`; +} + +// --- Assertions ------------------------------------------------------------- +export interface ValidateSbomInputs { + closure: BundlePackage[]; + cdxText: string; + spdxText: string; + exceptions: Exception[]; + cdxPath: string; + spdxPath: string; +} +export function validateSbom(inputs: ValidateSbomInputs): Check[] { + const checks: Check[] = []; + const add = (name: string, ok: boolean, detail?: string): void => { + checks.push(detail === undefined ? { name, ok } : { name, ok, detail }); + }; + + const cdx: CdxSbom = JSON.parse(inputs.cdxText); + const spdx: SpdxSbom = JSON.parse(inputs.spdxText); + const cdxMap = extractCdxPackages(cdx); + const spdxMap = extractSpdxPackages(spdx); + const truthByName = new Map(); + for (const p of inputs.closure) truthByName.set(p.name, p); + const exemptedNames = new Set(inputs.exceptions.map((e) => e.name)); + + // 1. Every truth-set package must exist in both SBOMs with matching fields. + for (const p of inputs.closure) { + const c = cdxMap.get(p.name); + add( + `cyclonedx has ${p.name}`, + c !== undefined, + c === undefined ? 'missing' : `${c.version} ${c.license}`, + ); + if (c) { + add( + `cyclonedx version matches: ${p.name}`, + c.version === p.version, + `sbom=${c.version} closure=${p.version}`, + ); + add( + `cyclonedx purl matches: ${p.name}`, + c.purl === p.purl, + `sbom=${c.purl} closure=${p.purl}`, + ); + add( + `cyclonedx license matches: ${p.name}`, + c.license === p.license, + `sbom=${c.license} closure=${p.license}`, + ); + } + const s = spdxMap.get(p.name); + add( + `spdx has ${p.name}`, + s !== undefined, + s === undefined ? 'missing' : `${s.version} ${s.license}`, + ); + if (s) { + add( + `spdx version matches: ${p.name}`, + s.version === p.version, + `sbom=${s.version} closure=${p.version}`, + ); + add(`spdx purl matches: ${p.name}`, s.purl === p.purl, `sbom=${s.purl} closure=${p.purl}`); + add( + `spdx license matches: ${p.name}`, + s.license === p.license, + `sbom=${s.license} closure=${p.license}`, + ); + } + } + + // 2. Both SBOMs must not carry extra packages that are not in the truth set, + // unless there is an active documented exception. + for (const [name, entry] of cdxMap) { + if (truthByName.has(name)) continue; + if (exemptedNames.has(name)) { + // exceptions apply to a specific (name, version) pair. + const ok = inputs.exceptions.some((e) => e.name === name && e.version === entry.version); + add( + `cyclonedx extra allowed: ${name}@${entry.version}`, + ok, + ok ? 'documented exception' : 'name allowlisted but version mismatch', + ); + continue; + } + add( + `cyclonedx has no ghost package: ${name}`, + false, + `${entry.version} ${entry.license} — not in bundle closure`, + ); + } + for (const [name, entry] of spdxMap) { + if (truthByName.has(name)) continue; + if (exemptedNames.has(name)) { + const ok = inputs.exceptions.some((e) => e.name === name && e.version === entry.version); + add( + `spdx extra allowed: ${name}@${entry.version}`, + ok, + ok ? 'documented exception' : 'name allowlisted but version mismatch', + ); + continue; + } + add( + `spdx has no ghost package: ${name}`, + false, + `${entry.version} ${entry.license} — not in bundle closure`, + ); + } + + // 3. The two SBOMs must agree on the third-party set (name+version+purl+license). + const cdxKeys = new Set( + [...cdxMap.entries()].map(([n, v]) => `${n}|${v.version}|${v.purl}|${v.license}`), + ); + const spdxKeys = new Set( + [...spdxMap.entries()].map(([n, v]) => `${n}|${v.version}|${v.purl}|${v.license}`), + ); + const onlyInCdx = [...cdxKeys].filter((k) => !spdxKeys.has(k)); + const onlyInSpdx = [...spdxKeys].filter((k) => !cdxKeys.has(k)); + add( + 'cyclonedx set equals spdx set', + onlyInCdx.length === 0 && onlyInSpdx.length === 0, + `only-cdx=${onlyInCdx.length} only-spdx=${onlyInSpdx.length}`, + ); + + // 4. Application component identity must agree between both SBOMs. + const cdxApp = cdx.metadata?.component; + const spdxApp = spdx.packages?.find((p) => p.name === APP_NAME); + add( + `cyclonedx application component is ${APP_NAME}`, + cdxApp?.name === APP_NAME, + `got ${cdxApp?.name ?? '(missing)'}`, + ); + add( + `spdx application package is ${APP_NAME}`, + spdxApp?.name === APP_NAME, + `got ${spdxApp?.name ?? '(missing)'}`, + ); + add( + 'application version matches across both SBOMs', + cdxApp?.version !== undefined && cdxApp.version === spdxApp?.versionInfo, + `cdx=${cdxApp?.version ?? '?'} spdx=${spdxApp?.versionInfo ?? '?'}`, + ); + + // 5. Byte-stability: re-serialize and compare with disk. This catches + // non-deterministic writers and hand edits that break reproducibility. + add( + 'cyclonedx SBOM is byte-stable (reserialization matches disk)', + reserialize(cdx) === inputs.cdxText, + `path=${relative(root, inputs.cdxPath)}`, + ); + add( + 'spdx SBOM is byte-stable (reserialization matches disk)', + reserialize(spdx) === inputs.spdxText, + `path=${relative(root, inputs.spdxPath)}`, + ); + + return checks; +} + +// --- Runner ------------------------------------------------------------------ +async function main(): Promise { + const cdxPath = sbomCdxArg + ? sbomCdxArg.startsWith('.') + ? join(root, sbomCdxArg) + : sbomCdxArg + : join(root, 'skills', 'calculate-birth-charts', 'sbom.cdx.json'); + const spdxPath = sbomSpdxArg + ? sbomSpdxArg.startsWith('.') + ? join(root, sbomSpdxArg) + : sbomSpdxArg + : join(root, 'skills', 'calculate-birth-charts', 'sbom.spdx.json'); + if (!existsSync(cdxPath)) { + process.stdout.write(`[FAIL] cyclonedx SBOM not found: ${relative(root, cdxPath)}\n`); + process.exit(1); + } + if (!existsSync(spdxPath)) { + process.stdout.write(`[FAIL] spdx SBOM not found: ${relative(root, spdxPath)}\n`); + process.exit(1); + } + const cdxText = readFileSync(cdxPath, 'utf8'); + const spdxText = readFileSync(spdxPath, 'utf8'); + const exceptions = loadExceptions(); + const closure = await computeTruthClosure(); + const checks = validateSbom({ closure, cdxText, spdxText, exceptions, cdxPath, spdxPath }); + + const failed = checks.filter((c) => !c.ok); + for (const c of checks) { + process.stdout.write( + `[${c.ok ? 'PASS' : 'FAIL'}] ${c.name}${c.detail ? ` (${c.detail})` : ''}\n`, + ); + } + process.stdout.write( + `\n${checks.length - failed.length}/${checks.length} sbom validation checks passed.\n`, + ); + if (failed.length > 0) { + process.stdout.write( + 'The committed SBOMs do not accurately describe the actual esbuild bundle closure.\n' + + 'Rebuild with `pnpm run build` and commit the regenerated sbom.cdx.json / sbom.spdx.json,\n' + + 'or add a documented, time-boxed entry to tools/validate-sbom.exceptions.json.\n', + ); + process.exit(1); + } +} + +const invokedAsScript = + process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]; +if (invokedAsScript) { + main().catch((err: unknown) => { + process.stdout.write(`[FAIL] validate-sbom crashed: ${(err as Error).message}\n`); + process.exit(1); + }); +} From d5f6211b780bb1dd5a5c0d9ca5c5d53d44a2b436 Mon Sep 17 00:00:00 2001 From: Jowitt13 <51783594+Jowitt13@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:00:23 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(sbom):=20bundle-closure=20classifyInput?= =?UTF-8?q?=20priority=20=E2=80=94=20node=5Fmodules=20segment=20beats=20pa?= =?UTF-8?q?ckages/=20substring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review reproduced a silent-miss bug in tools/lib/bundle-closure.ts. For the synthetic metafile input node_modules/synthetic-dep/packages/runtime/index.js computeBundleClosure returned: packages: [] ignored.repoInternal: ["node_modules/synthetic-dep/packages/runtime/index.js"] Root cause: classifyInput ran the `repoInternal` check with a broad `norm.includes('/packages/')` BEFORE the `node_modules` check, so any third-party package with its own `packages/` subdirectory (a common pattern in monorepo-shipped packages) got silently swept into repoInternal and lost from the closure. That is exactly the class of failure this P1 SBOM work was meant to prevent. Fix: 1. tools/lib/bundle-closure.ts - classifyInput rewritten with path-SEGMENT precise matching (`parts.includes('node_modules')`, not substring includes) and reordered so third-party wins over repoInternal. - repoInternal now only matches when the FIRST path segment is exactly `packages` (this repo's `packages//…` sources). - Backslashes and OS separators both normalise to `/` so segment logic works on Windows, POSIX and mixed-separator captures. - New `unknown` Category variant; anything unclassifiable is a hard error at computeBundleClosure. ignored.repoInternal from now on never carries a path with `node_modules` as a segment. 2. tools/lib/bundle-closure.test.ts (+6 offline cases) #16: exact repro — node_modules/synthetic-dep/packages/runtime/index.js resolves to synthetic-dep@1.0.0; ignored.repoInternal is empty. #17: scoped variant — node_modules/@scope/inner/packages/runtime/index.js resolves to @scope/inner. #18: pnpm-nested variant — node_modules/.pnpm/deep@1.2.3/node_modules/deep/packages/runtime/index.js resolves to deep@1.2.3. #19: repo-internal packages/orchestrator/src/x.ts still ignored as repoInternal (no regression). #20: unknown-shape path (somewhere/random/file.js) throws. #21: Windows-backslash `node_modules\\backslashy\\packages\\…` still resolves via segment normalisation. 3. docs/STATUS.md + docs/VALIDATION.md test count sync 509/31 -> 515/31 via byte-level patch; surrounding legacy mojibake preserved. check:doc-counts 12/12 confirms match. Real bundle metafile has no `node_modules//packages/` inputs today, so the fix is invisible to the actual SBOM output. Verified by SHA-256: engine.mjs 8b26c7bca1e36474 (unchanged vs 61ad624) sbom.cdx.json eb4b7bced3da42b1 (unchanged vs 61ad624) sbom.spdx.json 7139883154cdde14 (unchanged vs 61ad624) No SBOM regeneration needed; the value of this commit is preventing a future silent miss and turning it into a `validate:sbom` fail-closed. Local: pnpm vitest run tools/lib/bundle-closure.test.ts tools/validate-sbom.test.ts tools/validate-current-docs.test.ts -> 59/59. pnpm run test -> 515 tests / 31 files. pnpm run validate:sbom -> 86/86. pnpm run verify:cloud -> green end-to-end (validate:docs 126/126, check:doc-counts 12/12, scan:licenses 20/20, validate:sbom 86/86, scan:secrets clean over 223 files). pnpm run build x2 -> SBOM/engine SHA-256 identical vs 61ad624 and across runs. git diff --check -> exit 0. Not executed (deliberately, per instructions): No push. No PR. No merge. No tag or Release. No runtime dep added. No lockfile change. No incident-token access. No algorithm/facts/schema /ruleset/interpretation-rule change. --- docs/STATUS.md | 2 +- docs/VALIDATION.md | 2 +- tools/lib/bundle-closure.test.ts | 86 ++++++++++++++++++++++++++++++++ tools/lib/bundle-closure.ts | 63 ++++++++++++++++------- 4 files changed, 133 insertions(+), 20 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index dcd3d7c..58c404d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -179,7 +179,7 @@ never by hand. | Command | Result | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pnpm run typecheck` | clean (tsc strict over packages, tools, tests) | -| `pnpm run test` | 509 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | +| `pnpm run test` | 515 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | | `pnpm run build` | `engine.mjs` ?2.8 MB + `sbom.cdx.json` + `sbom.spdx.json` (6 runtime deps) | | `pnpm run validate:skill` | 40 / 40 (incl. scripts/ no-stray-files guard + CycloneDX/SPDX SBOM checks + validate-answer/lint-reading gate-workflow doc checks) | | `pnpm run validate:reading` | 53 / 53 (topic example libraries + output-spec structure + 无术语区 firewall; offline, no LLM) | diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 44e9a2b..b4b495a 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -59,7 +59,7 @@ the identical table in [STATUS.md](./STATUS.md) ("Commands & results"). Do not h resolve a disagreement; re-run the suite and copy the actual count. `pnpm run check:doc-counts` re-runs the suite and fails if either doc's `N tests / M files` count drifts from the real run. -- Typecheck: clean. Tests: **509 tests / 31 files ?all passing**. The Western provider +- Typecheck: clean. Tests: **515 tests / 31 files ?all passing**. The Western provider (astronomy-engine, VSOP87 + NOVAS) passes the ADR-0003 ??gate two ways: wrapper-consistency (vs astronomy-engine's own output) plus an **independent JPL Horizons golden** (10 bodies × 3 technical epochs fetched from the NASA/JPL Horizons service, query recorded in diff --git a/tools/lib/bundle-closure.test.ts b/tools/lib/bundle-closure.test.ts index 3ebab97..9c2f054 100644 --- a/tools/lib/bundle-closure.test.ts +++ b/tools/lib/bundle-closure.test.ts @@ -219,6 +219,92 @@ describe('bundle-closure: computeBundleClosure', () => { ), ).toThrow(/could not resolve package root/); }); + + // --- Regression: classifyInput priority bug (P1-fix) -------------------- + // Pre-fix, classifyInput ran `repoInternal` before `thirdParty` and used a + // substring `.includes('/packages/')`, so any third-party package with its + // own `packages/` subdirectory got silently swept into `ignored.repoInternal` + // and the bundle closure lost the package. These tests lock in the fix. + + it('16. classifyInput priority: node_modules/foo/packages/... resolves to foo (not repoInternal)', () => { + // Exact repro from the P1-fix report. + const dirSynth = nm('node_modules/synthetic-dep'); + const map = new Map([ + [dirSynth, { name: 'synthetic-dep', version: '1.0.0', license: 'MIT' }], + ]); + const r = computeBundleClosure( + { inputs: { 'node_modules/synthetic-dep/packages/runtime/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ); + expect(r.packages.map((p) => `${p.name}@${p.version}`)).toEqual(['synthetic-dep@1.0.0']); + // Must NOT be swept into repoInternal even though the path contains 'packages/'. + expect(r.ignored.repoInternal).toEqual([]); + }); + + it('17. classifyInput priority: scoped node_modules/@scope/pkg/packages/... resolves to @scope/pkg', () => { + const dirScopedInner = nm('node_modules/@scope/inner'); + const map = new Map([ + [dirScopedInner, { name: '@scope/inner', version: '3.4.5', license: 'MIT' }], + ]); + const r = computeBundleClosure( + { inputs: { 'node_modules/@scope/inner/packages/runtime/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ); + expect(r.packages[0]!.name).toBe('@scope/inner'); + expect(r.packages[0]!.version).toBe('3.4.5'); + expect(r.ignored.repoInternal).toEqual([]); + }); + + it('18. classifyInput priority: pnpm-nested pkg with internal packages/ subdir resolves to the pkg', () => { + const dirPnpmDeep = nm('node_modules/.pnpm/deep@1.2.3/node_modules/deep'); + const map = new Map([ + [dirPnpmDeep, { name: 'deep', version: '1.2.3', license: 'MIT' }], + ]); + const r = computeBundleClosure( + { + inputs: { + 'node_modules/.pnpm/deep@1.2.3/node_modules/deep/packages/runtime/index.js': {}, + }, + }, + { root: ROOT, readPackageJson: makeReader(map) }, + ); + expect(r.packages.map((p) => `${p.name}@${p.version}`)).toEqual(['deep@1.2.3']); + expect(r.ignored.repoInternal).toEqual([]); + }); + + it('19. repo-internal packages/ still routes to ignored.repoInternal (no regression)', () => { + const r = run(['packages/orchestrator/src/x.ts']); + expect(r.packages).toEqual([]); + expect(r.ignored.repoInternal).toEqual(['packages/orchestrator/src/x.ts']); + }); + + it('20. unknown path (neither node_modules nor packages/) -> throw (fail-closed)', () => { + expect(() => + computeBundleClosure( + { inputs: { 'somewhere/random/file.js': {} } }, + { root: ROOT, readPackageJson }, + ), + ).toThrow(/could not classify metafile input/); + }); + + it('21. Windows backslash path normalises so segment logic still runs', () => { + // A metafile that carries `\\`-separated paths (unusual but possible on + // Windows or in captured fixtures) must still be classified via segment + // matching, not fold to repoInternal via `\packages\`. + const map = new Map([ + [nm('node_modules/backslashy'), { name: 'backslashy', version: '0.0.9', license: 'MIT' }], + ]); + const r = computeBundleClosure( + { + inputs: { + [`node_modules\\backslashy\\packages\\runtime\\x.js`]: {}, + }, + }, + { root: ROOT, readPackageJson: makeReader(map) }, + ); + expect(r.packages.map((p) => `${p.name}@${p.version}`)).toEqual(['backslashy@0.0.9']); + expect(r.ignored.repoInternal).toEqual([]); + }); }); describe('bundle-closure: real disk smoke via tmpdir', () => { diff --git a/tools/lib/bundle-closure.ts b/tools/lib/bundle-closure.ts index 99be913..e34df50 100644 --- a/tools/lib/bundle-closure.ts +++ b/tools/lib/bundle-closure.ts @@ -137,32 +137,51 @@ export function extractLicense(pkg: RawPackageJson): string { } /** - * Classify a metafile input path. + * Classify a metafile input path into one of four categories, or 'unknown' + * if the path cannot be safely attributed. The classifier uses + * PATH-SEGMENT precise matching, never a substring `includes()`, so a + * third-party package with its own `packages/` subdirectory (a very common + * pattern in monorepo-shipped packages) is not silently swept into the + * repo-internal category. * - * The metafile paths esbuild emits are relative to the invocation cwd (in the - * ming build, that is the repo root). We only care about three exclusion - * categories plus everything else, which we treat as "third-party under - * node_modules and must be resolvable to a package root". + * Order matters: + * 1. virtual (esbuild synthetic inputs like `` and ``) + * 2. nodeBuiltin (`node:` prefixed identifiers) + * 3. thirdParty (ANY path with `node_modules` as a real path segment) — + * even when the path contains later segments named `packages`. + * 4. repoInternal (path whose FIRST segment is exactly `packages`) — the + * workspace's own `packages//…` source files. + * 5. unknown — anything that does not match the above. Callers must treat + * this as a hard error rather than a silent skip. */ type Category = - { kind: 'virtual' } | { kind: 'nodeBuiltin' } | { kind: 'repoInternal' } | { kind: 'thirdParty' }; + | { kind: 'virtual' } + | { kind: 'nodeBuiltin' } + | { kind: 'repoInternal' } + | { kind: 'thirdParty' } + | { kind: 'unknown' }; function classifyInput(input: string): Category { // esbuild emits synthetic entries like `` and ``. if (input.startsWith('<') || input.includes('\x00')) return { kind: 'virtual' }; if (input.startsWith('node:')) return { kind: 'nodeBuiltin' }; - // Normalise to forward slashes for consistent classification on Windows. - const norm = input.split(sep).join('/'); - if (norm.startsWith('packages/') || norm.includes('/packages/')) { - return { kind: 'repoInternal' }; - } - if (norm.startsWith('node_modules/') || norm.includes('/node_modules/')) { - return { kind: 'thirdParty' }; - } - // Anything else (a bare workspace-relative source that is neither packages/ - // nor node_modules) is likely a repo-internal file too; treat as internal so - // we never silently label it "unknown third-party". - return { kind: 'repoInternal' }; + // Normalise BOTH backslashes and the OS separator to `/` so segment + // matching works uniformly on Windows, POSIX and any mixed-separator input + // that a metafile might carry. + const norm = input.replace(/\\/g, '/'); + const parts = norm.split('/').filter((p) => p.length > 0); + + // PRIORITY: a path with `node_modules` as a real path SEGMENT is + // third-party — even if a later segment happens to be named `packages/`. + // Substring matching (`.includes('/packages/')`) would false-positive on + // `node_modules/foo/packages/runtime/index.js`. + if (parts.includes('node_modules')) return { kind: 'thirdParty' }; + + // repoInternal: only when the FIRST segment is exactly `packages`. + if (parts[0] === 'packages') return { kind: 'repoInternal' }; + + // Anything else cannot be safely attributed — fail-closed at the caller. + return { kind: 'unknown' }; } /** @@ -252,6 +271,14 @@ export function computeBundleClosure( ignored.repoInternal.push(raw); continue; } + if (c.kind === 'unknown') { + // Fail-closed: any metafile input that is neither obviously virtual, + // a Node built-in, a repo `packages/*` source, nor located under a + // real `node_modules` path segment is refused. Silently sweeping such + // paths into repoInternal (the pre-fix behaviour) hid third-party + // inputs whose paths happened to include a `packages/` subdirectory. + throw new Error(`could not classify metafile input: ${raw}`); + } // Third-party. Resolve to an absolute path relative to root, then walk up. const abs = isAbsolute(raw) ? raw : join(opts.root, raw); const { dir, json } = resolvePackageRoot(abs, { From 31ec7c7950e1ce2478a98c8fb7d663a1ba83d5bd Mon Sep 17 00:00:00 2001 From: Jowitt13 <51783594+Jowitt13@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:03:42 +0800 Subject: [PATCH 3/6] fix(sbom): canonical npm purl for scoped packages + CycloneDX SPDX-expression form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two SBOM standards-compliance fixes on top of the P1 bundle-closure work. 1. Canonical npm purl (purl-spec compliance) bundle-closure.ts previously emitted `pkg:npm/${name}@${version}` verbatim. For scoped packages that yields `pkg:npm/@scope/pkg@1.0.0`, which is NOT a canonical purl: the scope is the purl namespace and its `@` must be percent-encoded (`pkg:npm/%40scope/pkg@1.0.0`), otherwise SCA tools may fail to parse or match the component. - New single source of truth: npmPurl(name, version) exported from tools/lib/bundle-closure.ts. Unscoped -> plain form; scoped -> %40-encoded namespace; malformed scoped names (no slash / trailing slash) throw. - The closure builder, build-skill (via closure.purl), validate-sbom and ALL test fixtures now derive purls exclusively through npmPurl — no caller hand-writes purl strings anymore. - validate-sbom now checks each SBOM purl against the CANONICAL purl recomputed from (name, version) — check renamed to "cyclonedx/spdx purl is canonical: " — so a systematically wrong generator can never self-certify by matching its own output. 2. CycloneDX license id vs expression (CycloneDX 1.5 compliance) build-skill's old buildCycloneDxLicenseField treated only `startsWith('(')` as an expression, so a legal bare SPDX expression like `MIT OR Apache-2.0` (no outer parens) was mis-emitted as `license.id`. - New shared helpers in bundle-closure.ts: spdxKind(license): 'id' | 'expression' — single SPDX id charset check; expressions require well-formed OR/AND/WITH token structure with every operand a valid id; anything else throws (fail-closed — arbitrary prose is never silently treated as an SPDX id). cycloneDxLicenses(license): id -> [{ license: { id } }]; any expression (with or without outer parens) -> [{ expression }]. - build-skill.ts deletes its local paren-only helper and imports the shared one. - validate-sbom now records WHICH CycloneDX form each component used (license.id vs expression) and adds "cyclonedx license form is correct: " — an SPDX expression smuggled into license.id is rejected. Unrecognized license forms in the closure make validateSbom throw (fail-closed). 3. Offline synthetic tests (+20) bundle-closure.test.ts (+15): npmPurl unscoped/scoped/deep-scope/ malformed x2; spdxKind id x3 / parenthesised expression / bare expression (the regression) / AND + WITH / prose x2 -> throw / dangling operator -> throw / empty -> throw; cycloneDxLicenses id / bare expression / parenthesised expression. Existing scoped-purl assertion updated to the canonical %40 form. validate-sbom.test.ts (+5): #12 scoped canonical purl passes in both SBOMs end-to-end; #13 non-canonical raw-@ purl FAILs both canonical checks; #14 bare OR expression correctly in CycloneDX expression field passes; #15 expression smuggled into license.id FAILs the form check; #16 malformed license in closure -> validateSbom throws. All fixtures now import npmPurl instead of hand-writing purls. 4. Real-SBOM impact: NONE (verified) The current closure has 10 unscoped, single-MIT packages, so canonical purls and license forms are unchanged. SHA-256 identical vs d5f6211 and across two consecutive builds: engine.mjs 8b26c7bca1e36474 sbom.cdx.json eb4b7bced3da42b1 sbom.spdx.json 7139883154cdde14 5. Test count sync: 515/31 -> 535/31 in docs/STATUS.md + docs/VALIDATION.md (byte-level patch, mojibake preserved); check:doc-counts 12/12. Local results: pnpm vitest run tools/lib/bundle-closure.test.ts tools/validate-sbom.test.ts -> 60/60 (43 + 17). pnpm run test -> 535 tests / 31 files pass. pnpm run validate:sbom -> 96/96 (was 86; +10 from the new canonical purl and license-form checks over the 10-package closure). pnpm run verify:cloud -> green end-to-end, exit 0 (one earlier verify:cloud attempt failed in the vitest stage; two standalone `pnpm run test` runs and the re-run of verify:cloud all passed 535/535 — flake did not reproduce). pnpm run build x2 -> zero drift (hashes above). git diff --check -> exit 0. Not executed (deliberately, per instructions): No push. No PR. No merge. No tag or Release. No runtime dep added. No lockfile change. No incident-token access. No algorithm/facts/ schema/ruleset/interpretation-rule change. README carries no static test count. --- docs/STATUS.md | 2 +- docs/VALIDATION.md | 2 +- tools/build-skill.ts | 19 ++----- tools/lib/bundle-closure.test.ts | 68 ++++++++++++++++++++++- tools/lib/bundle-closure.ts | 71 +++++++++++++++++++++++- tools/validate-sbom.test.ts | 95 ++++++++++++++++++++++++++++++-- tools/validate-sbom.ts | 50 ++++++++++++++--- 7 files changed, 276 insertions(+), 31 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 58c404d..251fcb6 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -179,7 +179,7 @@ never by hand. | Command | Result | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pnpm run typecheck` | clean (tsc strict over packages, tools, tests) | -| `pnpm run test` | 515 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | +| `pnpm run test` | 535 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | | `pnpm run build` | `engine.mjs` ?2.8 MB + `sbom.cdx.json` + `sbom.spdx.json` (6 runtime deps) | | `pnpm run validate:skill` | 40 / 40 (incl. scripts/ no-stray-files guard + CycloneDX/SPDX SBOM checks + validate-answer/lint-reading gate-workflow doc checks) | | `pnpm run validate:reading` | 53 / 53 (topic example libraries + output-spec structure + 无术语区 firewall; offline, no LLM) | diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index b4b495a..4392ca6 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -59,7 +59,7 @@ the identical table in [STATUS.md](./STATUS.md) ("Commands & results"). Do not h resolve a disagreement; re-run the suite and copy the actual count. `pnpm run check:doc-counts` re-runs the suite and fails if either doc's `N tests / M files` count drifts from the real run. -- Typecheck: clean. Tests: **515 tests / 31 files ?all passing**. The Western provider +- Typecheck: clean. Tests: **535 tests / 31 files ?all passing**. The Western provider (astronomy-engine, VSOP87 + NOVAS) passes the ADR-0003 ??gate two ways: wrapper-consistency (vs astronomy-engine's own output) plus an **independent JPL Horizons golden** (10 bodies × 3 technical epochs fetched from the NASA/JPL Horizons service, query recorded in diff --git a/tools/build-skill.ts b/tools/build-skill.ts index c39f451..e5a6270 100644 --- a/tools/build-skill.ts +++ b/tools/build-skill.ts @@ -2,7 +2,11 @@ import { build } from 'esbuild'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { mkdirSync, statSync, writeFileSync } from 'node:fs'; -import { computeBundleClosure, type BundlePackage } from './lib/bundle-closure.ts'; +import { + computeBundleClosure, + cycloneDxLicenses, + type BundlePackage, +} from './lib/bundle-closure.ts'; /** * Bundle the deterministic engine into the Skill as a single self-contained ESM @@ -47,17 +51,6 @@ interface CycloneDxComponent { licenses: unknown[]; } -/** - * Build the CycloneDX `licenses` array for one component from the closure's - * SPDX id or OR expression. - * - "MIT" -> [{ license: { id: "MIT" } }] - * - "(MIT OR Apache-2.0)" -> [{ expression: "(MIT OR Apache-2.0)" }] - */ -function buildCycloneDxLicenseField(spdx: string): unknown[] { - if (spdx.startsWith('(')) return [{ expression: spdx }]; - return [{ license: { id: spdx } }]; -} - async function main(): Promise { mkdirSync(dirname(outfile), { recursive: true }); @@ -92,7 +85,7 @@ async function main(): Promise { name: p.name, version: p.version, purl: p.purl, - licenses: buildCycloneDxLicenseField(p.license), + licenses: cycloneDxLicenses(p.license), })); const sbomCdx = { diff --git a/tools/lib/bundle-closure.test.ts b/tools/lib/bundle-closure.test.ts index 9c2f054..b301162 100644 --- a/tools/lib/bundle-closure.test.ts +++ b/tools/lib/bundle-closure.test.ts @@ -5,7 +5,13 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, sep } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { computeBundleClosure, extractLicense } from './bundle-closure.ts'; +import { + computeBundleClosure, + cycloneDxLicenses, + extractLicense, + npmPurl, + spdxKind, +} from './bundle-closure.ts'; /** * In-memory readPackageJson factory: returns a function that answers @@ -84,7 +90,8 @@ describe('bundle-closure: computeBundleClosure', () => { it('3. scoped package at node_modules/@scope/', () => { const r = run(['node_modules/@scope/pkg/index.js']); expect(r.packages[0]!.name).toBe('@scope/pkg'); - expect(r.packages[0]!.purl).toBe('pkg:npm/@scope/pkg@0.1.0'); + // Canonical purl: the scope's `@` is percent-encoded per the purl spec. + expect(r.packages[0]!.purl).toBe('pkg:npm/%40scope/pkg@0.1.0'); }); it('4. pnpm nested layout attributes to the real package, not the hash dir', () => { @@ -307,6 +314,63 @@ describe('bundle-closure: computeBundleClosure', () => { }); }); +describe('bundle-closure: npmPurl (canonical purl)', () => { + it('unscoped package keeps the plain form', () => { + expect(npmPurl('zod', '4.4.3')).toBe('pkg:npm/zod@4.4.3'); + }); + it('scoped package percent-encodes the scope @', () => { + expect(npmPurl('@scope/pkg', '1.0.0')).toBe('pkg:npm/%40scope/pkg@1.0.0'); + }); + it('deeply-named scope works (@a-b.c/x)', () => { + expect(npmPurl('@a-b.c/x', '0.0.1')).toBe('pkg:npm/%40a-b.c/x@0.0.1'); + }); + it('malformed scoped name (no slash) -> throw', () => { + expect(() => npmPurl('@scopeonly', '1.0.0')).toThrow(/invalid scoped npm package name/); + }); + it('malformed scoped name (trailing slash) -> throw', () => { + expect(() => npmPurl('@scope/', '1.0.0')).toThrow(/invalid scoped npm package name/); + }); +}); + +describe('bundle-closure: spdxKind / cycloneDxLicenses', () => { + it('single SPDX id -> id', () => { + expect(spdxKind('MIT')).toBe('id'); + expect(spdxKind('Apache-2.0')).toBe('id'); + expect(spdxKind('GPL-2.0+')).toBe('id'); + }); + it('parenthesised OR expression -> expression', () => { + expect(spdxKind('(MIT OR Apache-2.0)')).toBe('expression'); + }); + it('bare OR expression WITHOUT parens -> expression (regression: was mis-typed as id)', () => { + expect(spdxKind('MIT OR Apache-2.0')).toBe('expression'); + }); + it('AND / WITH operators -> expression', () => { + expect(spdxKind('MIT AND BSD-3-Clause')).toBe('expression'); + expect(spdxKind('GPL-2.0 WITH Classpath-exception-2.0')).toBe('expression'); + }); + it('arbitrary prose -> throw (never silently treated as SPDX id)', () => { + expect(() => spdxKind('see license file')).toThrow(/unrecognized SPDX license form/); + expect(() => spdxKind('Custom License v2!')).toThrow(/unrecognized SPDX license form/); + }); + it('dangling operator -> throw', () => { + expect(() => spdxKind('MIT OR')).toThrow(/unrecognized SPDX license form/); + }); + it('empty -> throw', () => { + expect(() => spdxKind(' ')).toThrow(/empty SPDX license/); + }); + it('cycloneDxLicenses: id form -> license.id entry', () => { + expect(cycloneDxLicenses('MIT')).toEqual([{ license: { id: 'MIT' } }]); + }); + it('cycloneDxLicenses: unparenthesised expression -> expression entry', () => { + expect(cycloneDxLicenses('MIT OR Apache-2.0')).toEqual([{ expression: 'MIT OR Apache-2.0' }]); + }); + it('cycloneDxLicenses: parenthesised expression -> expression entry', () => { + expect(cycloneDxLicenses('(MIT OR Apache-2.0)')).toEqual([ + { expression: '(MIT OR Apache-2.0)' }, + ]); + }); +}); + describe('bundle-closure: real disk smoke via tmpdir', () => { it('resolves against a real node_modules layout without touching the repo tree', () => { const dir = mkdtempSync(join(tmpdir(), 'bc-')); diff --git a/tools/lib/bundle-closure.ts b/tools/lib/bundle-closure.ts index e34df50..acd533d 100644 --- a/tools/lib/bundle-closure.ts +++ b/tools/lib/bundle-closure.ts @@ -136,6 +136,75 @@ export function extractLicense(pkg: RawPackageJson): string { return ''; } +/** + * Canonical npm Package URL (purl) per the purl spec + * (https://github.com/package-url/purl-spec): for scoped packages the scope + * is the purl namespace and its `@` MUST be percent-encoded, e.g. + * pkg:npm/%40scope/name@1.2.3 + * Unscoped packages keep the plain form: + * pkg:npm/name@1.2.3 + * + * This is the ONLY place npm purls are generated — build-skill, validate-sbom + * and test fixtures all call this function so no caller can hand-write a + * divergent (non-canonical) purl string. + */ +export function npmPurl(name: string, version: string): string { + if (name.startsWith('@')) { + const slash = name.indexOf('/'); + if (slash <= 1 || slash === name.length - 1) { + throw new Error(`invalid scoped npm package name: ${name}`); + } + const scope = name.slice(1, slash); // without the leading '@' + const rest = name.slice(slash + 1); + return `pkg:npm/%40${scope}/${rest}@${version}`; + } + return `pkg:npm/${name}@${version}`; +} + +/** One bare SPDX id: letters, digits, dot, dash, optional trailing `+`. */ +const SPDX_ID_RE = /^[A-Za-z0-9.-]+\+?$/; + +/** + * Distinguish a single SPDX license id from an SPDX license expression. + * + * 'MIT' -> 'id' + * 'Apache-2.0' -> 'id' + * 'GPL-2.0+' -> 'id' + * 'MIT OR Apache-2.0' -> 'expression' (no parens required!) + * '(MIT OR Apache-2.0)' -> 'expression' + * 'A AND B', 'A WITH e' -> 'expression' + * anything else -> throw (fail-closed; arbitrary prose is never + * silently treated as an SPDX id) + */ +export function spdxKind(license: string): 'id' | 'expression' { + const t = license.trim(); + if (t === '') throw new Error('empty SPDX license'); + if (SPDX_ID_RE.test(t)) return 'id'; + // Candidate expression: strip parens, tokenize, and require a well-formed + // alternation of SPDX ids and OR/AND/WITH operators. + const tokens = t + .replace(/[()]/g, ' ') + .split(/\s+/) + .filter((x) => x.length > 0); + const isOp = (x: string): boolean => /^(OR|AND|WITH)$/i.test(x); + const ops = tokens.filter(isOp); + const operands = tokens.filter((x) => !isOp(x)); + if (ops.length >= 1 && operands.length >= 2 && operands.every((x) => SPDX_ID_RE.test(x))) { + return 'expression'; + } + throw new Error(`unrecognized SPDX license form: "${license}"`); +} + +/** + * Build the CycloneDX `licenses` array for one component. Single SPDX ids go + * into `{ license: { id } }`; every expression (with or without outer parens) + * goes into `{ expression }`. Unrecognized forms throw via spdxKind. + */ +export function cycloneDxLicenses(license: string): unknown[] { + const t = license.trim(); + return spdxKind(t) === 'id' ? [{ license: { id: t } }] : [{ expression: t }]; +} + /** * Classify a metafile input path into one of four categories, or 'unknown' * if the path cannot be safely attributed. The classifier uses @@ -305,7 +374,7 @@ export function computeBundleClosure( name, version, license, - purl: `pkg:npm/${name}@${version}`, + purl: npmPurl(name, version), packageRoot: dir, inputs: [relative(opts.root, abs).split(sep).join('/')], }); diff --git a/tools/validate-sbom.test.ts b/tools/validate-sbom.test.ts index ae1792d..3bdd68c 100644 --- a/tools/validate-sbom.test.ts +++ b/tools/validate-sbom.test.ts @@ -3,6 +3,7 @@ // memory. Nothing on disk is read or written; no real SBOM is touched. import { describe, expect, it } from 'vitest'; import { validateSbom, type ValidateSbomInputs } from './validate-sbom.ts'; +import { npmPurl } from './lib/bundle-closure.ts'; import type { BundlePackage } from './lib/bundle-closure.ts'; /** Build a canonical BundlePackage; individual tests can override fields. */ @@ -11,7 +12,7 @@ function pkg(name: string, version: string, license = 'MIT'): BundlePackage { name, version, license, - purl: `pkg:npm/${name}@${version}`, + purl: npmPurl(name, version), packageRoot: `/fake/${name}`, inputs: [`node_modules/${name}/index.js`], }; @@ -42,7 +43,7 @@ function cdx( type: c.type ?? 'library', name: c.name, version: c.version, - purl: c.purl ?? `pkg:npm/${c.name}@${c.version}`, + purl: c.purl ?? npmPurl(c.name, c.version), licenses: c.expression ? [{ expression: c.expression }] : [{ license: { id: c.license ?? 'MIT' } }], @@ -63,7 +64,7 @@ function spdx(components: { name: string; version: string; purl?: string; licens { referenceCategory: 'PACKAGE-MANAGER', referenceType: 'purl', - referenceLocator: c.purl ?? `pkg:npm/${c.name}@${c.version}`, + referenceLocator: c.purl ?? npmPurl(c.name, c.version), }, ], })); @@ -198,7 +199,7 @@ describe('validate-sbom (offline synthetic)', () => { ]); t.cdxText = ser(cdxObj); const failed = failedNames(t); - expect(failed).toContain('cyclonedx purl matches: zod'); + expect(failed).toContain('cyclonedx purl is canonical: zod'); }); it('7. application component name drift -> FAIL', () => { @@ -294,4 +295,90 @@ describe('validate-sbom (offline synthetic)', () => { expect(failed).toContain('spdx has moment'); expect(failed).toContain('cyclonedx set equals spdx set'); }); + + // --- P1-fix-2: canonical purl + CycloneDX SPDX-expression form ----------- + + it('12. scoped package: canonical %40-encoded purl passes end-to-end in both SBOMs', () => { + const closure = [pkg('@scope/pkg', '1.0.0')]; + // Fixture helpers derive purls via the same shared npmPurl -> canonical. + const cdxObj = cdx([{ name: '@scope/pkg', version: '1.0.0' }]); + const spdxObj = spdx([{ name: '@scope/pkg', version: '1.0.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/sbom.cdx.json', + spdxPath: '/mem/sbom.spdx.json', + }; + expect(failedNames(t)).toEqual([]); + // Sanity: the canonical purl really is the %40-encoded form. + expect(closure[0]!.purl).toBe('pkg:npm/%40scope/pkg@1.0.0'); + }); + + it('13. scoped package with NON-canonical raw-@ purl in both SBOMs -> canonical checks FAIL', () => { + const closure = [pkg('@scope/pkg', '1.0.0')]; + const raw = 'pkg:npm/@scope/pkg@1.0.0'; // NOT percent-encoded — non-canonical + const cdxObj = cdx([{ name: '@scope/pkg', version: '1.0.0', purl: raw }]); + const spdxObj = spdx([{ name: '@scope/pkg', version: '1.0.0', purl: raw }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/sbom.cdx.json', + spdxPath: '/mem/sbom.spdx.json', + }; + const failed = failedNames(t); + expect(failed).toContain('cyclonedx purl is canonical: @scope/pkg'); + expect(failed).toContain('spdx purl is canonical: @scope/pkg'); + }); + + it('14. bare OR expression license correctly in CycloneDX expression field -> pass', () => { + const closure = [pkg('dual', '3.0.0', 'MIT OR Apache-2.0')]; + const cdxObj = cdx([{ name: 'dual', version: '3.0.0', expression: 'MIT OR Apache-2.0' }]); + const spdxObj = spdx([{ name: 'dual', version: '3.0.0', license: 'MIT OR Apache-2.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/sbom.cdx.json', + spdxPath: '/mem/sbom.spdx.json', + }; + expect(failedNames(t)).toEqual([]); + }); + + it('15. expression smuggled into license.id -> form check FAILs', () => { + const closure = [pkg('dual', '3.0.0', 'MIT OR Apache-2.0')]; + // Fixture writes the expression as license.id (the old buggy behaviour of + // the paren-only detector). validate-sbom must reject the disguise. + const cdxObj = cdx([{ name: 'dual', version: '3.0.0', license: 'MIT OR Apache-2.0' }]); + const spdxObj = spdx([{ name: 'dual', version: '3.0.0', license: 'MIT OR Apache-2.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/sbom.cdx.json', + spdxPath: '/mem/sbom.spdx.json', + }; + const failed = failedNames(t); + expect(failed).toContain('cyclonedx license form is correct: dual'); + }); + + it('16. malformed/unknown license in closure -> validateSbom throws (fail-closed)', () => { + const closure = [pkg('weird', '1.0.0', 'see license file')]; + const cdxObj = cdx([{ name: 'weird', version: '1.0.0', license: 'see license file' }]); + const spdxObj = spdx([{ name: 'weird', version: '1.0.0', license: 'see license file' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/sbom.cdx.json', + spdxPath: '/mem/sbom.spdx.json', + }; + expect(() => validateSbom(t)).toThrow(/unrecognized SPDX license form/); + }); }); diff --git a/tools/validate-sbom.ts b/tools/validate-sbom.ts index 5390551..8116754 100644 --- a/tools/validate-sbom.ts +++ b/tools/validate-sbom.ts @@ -2,7 +2,12 @@ import { build } from 'esbuild'; import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { computeBundleClosure, type BundlePackage } from './lib/bundle-closure.ts'; +import { + computeBundleClosure, + npmPurl, + spdxKind, + type BundlePackage, +} from './lib/bundle-closure.ts'; /** * Reverse gate for the CycloneDX + SPDX SBOMs shipped with the Skill. @@ -128,18 +133,29 @@ interface SpdxSbom { packages?: SpdxPackage[]; } -/** Read a CycloneDX SBOM and normalise the third-party components. */ +/** Read a CycloneDX SBOM and normalise the third-party components. + * `form` records HOW the license was written: `id` (license.id), `expression` + * (SPDX expression field) or `none`. validate-sbom uses it to reject SBOMs + * that smuggle an SPDX expression into `license.id`. */ function extractCdxPackages( sbom: CdxSbom, -): Map { - const out = new Map(); +): Map< + string, + { version: string; purl: string; license: string; form: 'id' | 'expression' | 'none' } +> { + const out = new Map< + string, + { version: string; purl: string; license: string; form: 'id' | 'expression' | 'none' } + >(); for (const c of sbom.components ?? []) { if (c.type !== 'library') continue; if (typeof c.name !== 'string' || typeof c.version !== 'string' || typeof c.purl !== 'string') continue; const lic = c.licenses?.[0]; + const form: 'id' | 'expression' | 'none' = + lic?.expression !== undefined ? 'expression' : lic?.license?.id !== undefined ? 'id' : 'none'; const license = lic?.expression ?? lic?.license?.id ?? ''; - out.set(c.name, { version: c.version, purl: c.purl, license }); + out.set(c.name, { version: c.version, purl: c.purl, license, form }); } return out; } @@ -219,7 +235,14 @@ export function validateSbom(inputs: ValidateSbomInputs): Check[] { const exemptedNames = new Set(inputs.exceptions.map((e) => e.name)); // 1. Every truth-set package must exist in both SBOMs with matching fields. + // Purls are checked against the CANONICAL form recomputed here from + // (name, version) via the shared npmPurl — not merely string-equality + // with the closure — so a systematically wrong purl generator can never + // self-certify. CycloneDX license FORM is checked against spdxKind so an + // expression can never masquerade as `license.id`. for (const p of inputs.closure) { + const canonicalPurl = npmPurl(p.name, p.version); + const expectedForm = spdxKind(p.license); // throws on unrecognized license (fail-closed) const c = cdxMap.get(p.name); add( `cyclonedx has ${p.name}`, @@ -233,15 +256,20 @@ export function validateSbom(inputs: ValidateSbomInputs): Check[] { `sbom=${c.version} closure=${p.version}`, ); add( - `cyclonedx purl matches: ${p.name}`, - c.purl === p.purl, - `sbom=${c.purl} closure=${p.purl}`, + `cyclonedx purl is canonical: ${p.name}`, + c.purl === canonicalPurl, + `sbom=${c.purl} canonical=${canonicalPurl}`, ); add( `cyclonedx license matches: ${p.name}`, c.license === p.license, `sbom=${c.license} closure=${p.license}`, ); + add( + `cyclonedx license form is correct: ${p.name}`, + c.form === expectedForm, + `sbom-form=${c.form} expected=${expectedForm}`, + ); } const s = spdxMap.get(p.name); add( @@ -255,7 +283,11 @@ export function validateSbom(inputs: ValidateSbomInputs): Check[] { s.version === p.version, `sbom=${s.version} closure=${p.version}`, ); - add(`spdx purl matches: ${p.name}`, s.purl === p.purl, `sbom=${s.purl} closure=${p.purl}`); + add( + `spdx purl is canonical: ${p.name}`, + s.purl === canonicalPurl, + `sbom=${s.purl} canonical=${canonicalPurl}`, + ); add( `spdx license matches: ${p.name}`, s.license === p.license, From f945014c8d37ca91945472a43f2d6670df25079f Mon Sep 17 00:00:00 2001 From: Jowitt13 <51783594+Jowitt13@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:18:38 +0800 Subject: [PATCH 4/6] fix(sbom): spdxKind recursive-descent parser + validate-sbom duplicate detection Bug A: spdxKind() accepted malformed SPDX expressions because it only counted operators vs operands. Replaced with a recursive-descent parser that validates token sequence, bracket pairing, and operator grammar. Rejects: MIT OR OR Apache-2.0, MIT Apache-2.0 OR, ((MIT OR Apache-2.0), OR MIT, (), MIT WITH (dangling), consecutive ids/ops, unmatched parens. Accepts: MIT OR Apache-2.0, (MIT OR Apache-2.0), GPL-2.0 WITH Classpath-exception-2.0, (A OR (B AND C)). Bug B: validate-sbom's extractCdxPackages/extractSpdxPackages silently overwrote duplicates via Map.set. Added explicit duplicate-name scans (fail-closed: any third-party name appearing >=2 times is a hard FAIL). Tests: +9 offline synthetic (+7 spdxKind rejection/acceptance + 2 validate-sbom duplicate detection). Count 535->544/31. SBOM SHA-256 unchanged vs 31ec7c7 (8b26c7bc/eb4b7bce/71398831). verify:cloud green, build x2 zero-drift, git diff --check clean. Local commit only; not pushed. --- docs/STATUS.md | 2 +- docs/VALIDATION.md | 2 +- tools/lib/bundle-closure.test.ts | 28 +++++++- tools/lib/bundle-closure.ts | 114 +++++++++++++++++++++++++------ tools/validate-sbom.test.ts | 61 ++++++++++++++++- tools/validate-sbom.ts | 19 ++++++ 6 files changed, 200 insertions(+), 26 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 251fcb6..8fdb532 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -179,7 +179,7 @@ never by hand. | Command | Result | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pnpm run typecheck` | clean (tsc strict over packages, tools, tests) | -| `pnpm run test` | 535 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | +| `pnpm run test` | 544 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | | `pnpm run build` | `engine.mjs` ?2.8 MB + `sbom.cdx.json` + `sbom.spdx.json` (6 runtime deps) | | `pnpm run validate:skill` | 40 / 40 (incl. scripts/ no-stray-files guard + CycloneDX/SPDX SBOM checks + validate-answer/lint-reading gate-workflow doc checks) | | `pnpm run validate:reading` | 53 / 53 (topic example libraries + output-spec structure + 无术语区 firewall; offline, no LLM) | diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 4392ca6..364331e 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -59,7 +59,7 @@ the identical table in [STATUS.md](./STATUS.md) ("Commands & results"). Do not h resolve a disagreement; re-run the suite and copy the actual count. `pnpm run check:doc-counts` re-runs the suite and fails if either doc's `N tests / M files` count drifts from the real run. -- Typecheck: clean. Tests: **535 tests / 31 files ?all passing**. The Western provider +- Typecheck: clean. Tests: **544 tests / 31 files ?all passing**. The Western provider (astronomy-engine, VSOP87 + NOVAS) passes the ADR-0003 ??gate two ways: wrapper-consistency (vs astronomy-engine's own output) plus an **independent JPL Horizons golden** (10 bodies × 3 technical epochs fetched from the NASA/JPL Horizons service, query recorded in diff --git a/tools/lib/bundle-closure.test.ts b/tools/lib/bundle-closure.test.ts index b301162..5c50208 100644 --- a/tools/lib/bundle-closure.test.ts +++ b/tools/lib/bundle-closure.test.ts @@ -349,15 +349,37 @@ describe('bundle-closure: spdxKind / cycloneDxLicenses', () => { expect(spdxKind('GPL-2.0 WITH Classpath-exception-2.0')).toBe('expression'); }); it('arbitrary prose -> throw (never silently treated as SPDX id)', () => { - expect(() => spdxKind('see license file')).toThrow(/unrecognized SPDX license form/); - expect(() => spdxKind('Custom License v2!')).toThrow(/unrecognized SPDX license form/); + expect(() => spdxKind('see license file')).toThrow(/SPDX parse error/); + expect(() => spdxKind('Custom License v2!')).toThrow(/SPDX parse error/); }); it('dangling operator -> throw', () => { - expect(() => spdxKind('MIT OR')).toThrow(/unrecognized SPDX license form/); + expect(() => spdxKind('MIT OR')).toThrow(/SPDX parse error/); }); it('empty -> throw', () => { expect(() => spdxKind(' ')).toThrow(/empty SPDX license/); }); + // --- P1-fix-3 regression: recursive-descent parser catches malformed expressions --- + it('consecutive operators (MIT OR OR Apache-2.0) -> throw', () => { + expect(() => spdxKind('MIT OR OR Apache-2.0')).toThrow(/SPDX parse error/); + }); + it('trailing operator + consecutive ids (MIT Apache-2.0 OR) -> throw', () => { + expect(() => spdxKind('MIT Apache-2.0 OR')).toThrow(/SPDX parse error/); + }); + it('unmatched parens ((MIT OR Apache-2.0) -> throw', () => { + expect(() => spdxKind('((MIT OR Apache-2.0)')).toThrow(/SPDX parse error/); + }); + it('leading operator (OR MIT) -> throw', () => { + expect(() => spdxKind('OR MIT')).toThrow(/SPDX parse error/); + }); + it('empty parens () -> throw', () => { + expect(() => spdxKind('()')).toThrow(/SPDX parse error/); + }); + it('dangling WITH (MIT WITH) -> throw', () => { + expect(() => spdxKind('MIT WITH')).toThrow(/SPDX parse error/); + }); + it('nested parens (A OR (B AND C)) -> expression (valid)', () => { + expect(spdxKind('(A OR (B AND C))')).toBe('expression'); + }); it('cycloneDxLicenses: id form -> license.id entry', () => { expect(cycloneDxLicenses('MIT')).toEqual([{ license: { id: 'MIT' } }]); }); diff --git a/tools/lib/bundle-closure.ts b/tools/lib/bundle-closure.ts index acd533d..2c040df 100644 --- a/tools/lib/bundle-closure.ts +++ b/tools/lib/bundle-closure.ts @@ -165,34 +165,108 @@ export function npmPurl(name: string, version: string): string { const SPDX_ID_RE = /^[A-Za-z0-9.-]+\+?$/; /** - * Distinguish a single SPDX license id from an SPDX license expression. + * Distinguish a single SPDX license id from an SPDX license expression using + * a proper recursive-descent parser that validates token sequence, bracket + * pairing, and operator grammar. This replaces the previous count-based + * heuristic which accepted malformed expressions such as + * `MIT OR OR Apache-2.0`, `MIT Apache-2.0 OR`, and `((MIT OR Apache-2.0)`. * - * 'MIT' -> 'id' - * 'Apache-2.0' -> 'id' - * 'GPL-2.0+' -> 'id' - * 'MIT OR Apache-2.0' -> 'expression' (no parens required!) - * '(MIT OR Apache-2.0)' -> 'expression' - * 'A AND B', 'A WITH e' -> 'expression' - * anything else -> throw (fail-closed; arbitrary prose is never - * silently treated as an SPDX id) + * Grammar (simplified SPDX compound expression): + * + * expr := term ((OR | AND) term)* + * term := atom (WITH atom)? + * atom := id | '(' expr ')' + * + * Where: + * id = SPDX_ID_RE (letters, digits, dot, dash, optional trailing `+`) + * + * Rejects: consecutive operators, consecutive ids without an operator, + * unmatched parens, empty parens, trailing/leading operators, dangling WITH. + * + * Returns: + * 'id' if the entire string is a single SPDX id + * 'expression' if it is a well-formed compound expression + * throws on anything else (fail-closed) */ export function spdxKind(license: string): 'id' | 'expression' { const t = license.trim(); if (t === '') throw new Error('empty SPDX license'); - if (SPDX_ID_RE.test(t)) return 'id'; - // Candidate expression: strip parens, tokenize, and require a well-formed - // alternation of SPDX ids and OR/AND/WITH operators. - const tokens = t - .replace(/[()]/g, ' ') + if (SPDX_ID_RE.test(t)) return 'id'; // fast path: single bare id + + // Tokenize: insert spaces around parens, split, filter empties. + const tokens: string[] = t + .replace(/[()]/g, (m) => ` ${m} `) .split(/\s+/) .filter((x) => x.length > 0); - const isOp = (x: string): boolean => /^(OR|AND|WITH)$/i.test(x); - const ops = tokens.filter(isOp); - const operands = tokens.filter((x) => !isOp(x)); - if (ops.length >= 1 && operands.length >= 2 && operands.every((x) => SPDX_ID_RE.test(x))) { - return 'expression'; + if (tokens.length === 0) throw new Error('empty SPDX license'); + if (tokens.length === 1 && SPDX_ID_RE.test(tokens[0]!)) return 'id'; + + // Recursive-descent parser. + let pos = 0; + const peek = (): string | undefined => tokens[pos]; + const advance = (): string => { + if (pos >= tokens.length) { + throw new Error(`SPDX parse error in "${license}": unexpected end of expression`); + } + return tokens[pos++]!; + }; + const isOp = (x: string): boolean => x === 'OR' || x === 'AND'; + + function parseExpr(): void { + parseTerm(); + while (pos < tokens.length && peek() !== ')' && isOp(peek()!)) { + advance(); // consume OR | AND + parseTerm(); + } + } + + function parseTerm(): void { + parseAtom(); + // Optional WITH clause (a license exception like `GPL-2.0 WITH Classpath-exception-2.0`). + if (pos < tokens.length && peek() === 'WITH') { + advance(); // consume WITH + // The WITH operand must be a single SPDX id (not a sub-expression). + const ex = advance(); + if (!SPDX_ID_RE.test(ex)) { + throw new Error( + `SPDX parse error in "${license}": expected exception id after WITH, got "${ex}"`, + ); + } + } + } + + function parseAtom(): void { + const tok = advance(); + if (tok === '(') { + if (peek() === ')') { + throw new Error(`SPDX parse error in "${license}": empty parentheses`); + } + parseExpr(); + const close = advance(); + if (close !== ')') { + throw new Error(`SPDX parse error in "${license}": expected ')' but got "${close}"`); + } + return; + } + if (tok === ')') { + throw new Error(`SPDX parse error in "${license}": unexpected ')'`); + } + if (isOp(tok) || tok === 'WITH') { + throw new Error( + `SPDX parse error in "${license}": unexpected operator "${tok}" where id was expected`, + ); + } + if (!SPDX_ID_RE.test(tok)) { + throw new Error(`SPDX parse error in "${license}": "${tok}" is not a valid SPDX id`); + } + // Valid id — consumed. + } + + parseExpr(); + if (pos !== tokens.length) { + throw new Error(`SPDX parse error in "${license}": unexpected trailing token "${tokens[pos]}"`); } - throw new Error(`unrecognized SPDX license form: "${license}"`); + return 'expression'; } /** diff --git a/tools/validate-sbom.test.ts b/tools/validate-sbom.test.ts index 3bdd68c..06c26b0 100644 --- a/tools/validate-sbom.test.ts +++ b/tools/validate-sbom.test.ts @@ -379,6 +379,65 @@ describe('validate-sbom (offline synthetic)', () => { cdxPath: '/mem/sbom.cdx.json', spdxPath: '/mem/sbom.spdx.json', }; - expect(() => validateSbom(t)).toThrow(/unrecognized SPDX license form/); + expect(() => validateSbom(t)).toThrow(/SPDX parse error/); + }); + + // --- P1-fix-3: duplicate component/package detection ----------------------- + + it('17. CycloneDX with two same-name library components -> FAIL duplicate', () => { + const closure = [pkg('foo', '1.0.0')]; + // Build CycloneDX with `foo` listed twice. + const cdxObj = { + ...JSON.parse(ser(cdx([{ name: 'foo', version: '1.0.0' }]))), + components: [ + { + type: 'library', + name: 'foo', + version: '1.0.0', + purl: npmPurl('foo', '1.0.0'), + licenses: [{ license: { id: 'MIT' } }], + }, + { + type: 'library', + name: 'foo', + version: '1.0.0', + purl: npmPurl('foo', '1.0.0'), + licenses: [{ license: { id: 'MIT' } }], + }, + ], + }; + const spdxObj = spdx([{ name: 'foo', version: '1.0.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/mem/cdx', + spdxPath: '/mem/spdx', + }; + const failed = failedNames(t); + expect(failed).toContain('cyclonedx no duplicate library component: foo'); + }); + + it('18. SPDX with two same-name third-party packages -> FAIL duplicate', () => { + const closure = [pkg('bar', '2.0.0')]; + const cdxObj = cdx([{ name: 'bar', version: '2.0.0' }]); + // Build SPDX with `bar` listed twice. + const baseSpdx = JSON.parse(ser(spdx([{ name: 'bar', version: '2.0.0' }]))) as Record< + string, + unknown + >; + const barPkg = (baseSpdx.packages as unknown[]).find((p: any) => p.name === 'bar'); + (baseSpdx.packages as unknown[]).push(barPkg); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(baseSpdx), + exceptions: [], + cdxPath: '/mem/cdx', + spdxPath: '/mem/spdx', + }; + const failed = failedNames(t); + expect(failed).toContain('spdx no duplicate third-party package: bar'); }); }); diff --git a/tools/validate-sbom.ts b/tools/validate-sbom.ts index 8116754..f06103e 100644 --- a/tools/validate-sbom.ts +++ b/tools/validate-sbom.ts @@ -234,6 +234,25 @@ export function validateSbom(inputs: ValidateSbomInputs): Check[] { for (const p of inputs.closure) truthByName.set(p.name, p); const exemptedNames = new Set(inputs.exceptions.map((e) => e.name)); + // 0. Duplicate-name detection: any SBOM that lists the same third-party + // package name more than once is malformed. Previous code used Map.set + // which silently overwrote duplicates; now we fail-closed. + const cdxLibNames = (cdx.components ?? []) + .filter((c) => c.type === 'library') + .map((c) => c.name) + .filter((n): n is string => typeof n === 'string'); + const cdxDups = [...new Set(cdxLibNames.filter((n, i) => cdxLibNames.indexOf(n) !== i))]; + for (const d of cdxDups) { + add(`cyclonedx no duplicate library component: ${d}`, false, 'appears more than once'); + } + const spdxPkgNames = (spdx.packages ?? []) + .filter((p) => typeof p.name === 'string' && p.name !== APP_NAME) + .map((p) => p.name as string); + const spdxDups = [...new Set(spdxPkgNames.filter((n, i) => spdxPkgNames.indexOf(n) !== i))]; + for (const d of spdxDups) { + add(`spdx no duplicate third-party package: ${d}`, false, 'appears more than once'); + } + // 1. Every truth-set package must exist in both SBOMs with matching fields. // Purls are checked against the CANONICAL form recomputed here from // (name, version) via the shared npmPurl — not merely string-equality From 1a0365650d5fc9410d486b1dbb3cf57c44e76468 Mon Sep 17 00:00:00 2001 From: Jowitt13 <51783594+Jowitt13@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:44:10 +0800 Subject: [PATCH 5/6] fix(sbom): spdxKind WITH position restriction + validate-sbom structural field checks 1. spdxKind parser: parseAtom now returns 'id'|'paren'; parseTerm only permits WITH when the preceding atom is a bare SPDX id. A compound (parenthesized) expression followed by WITH is rejected: (MIT OR Apache-2.0) WITH X -> throw MIT WITH (A OR B) -> throw (exception must be bare id) MIT WITH WITH X -> throw (double WITH) Valid uses unchanged: MIT WITH X, MIT WITH X OR Y, GPL-2.0 WITH Y. 2. validate-sbom structural completeness: after duplicate-detection and before per-package matching, every CycloneDX library component and every SPDX third-party package is checked for all required identity fields (name, version, purl, license). Missing/empty fields are a hard FAIL; previously they were silently skipped via continue. Tests: +11 offline synthetic (4 WITH-position + 7 structural-field). Count 544->555/31. SBOM SHA-256 unchanged (8b26c7bc/eb4b7bce/71398831). verify:cloud green. build x2 zero-drift. git diff --check clean. Local commit only; not pushed. --- docs/STATUS.md | 2 +- docs/VALIDATION.md | 2 +- tools/lib/bundle-closure.test.ts | 15 +++ tools/lib/bundle-closure.ts | 17 ++- tools/validate-sbom.test.ts | 177 +++++++++++++++++++++++++++++++ tools/validate-sbom.ts | 35 ++++++ 6 files changed, 241 insertions(+), 7 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 8fdb532..cc1bd64 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -179,7 +179,7 @@ never by hand. | Command | Result | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pnpm run typecheck` | clean (tsc strict over packages, tools, tests) | -| `pnpm run test` | 544 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | +| `pnpm run test` | 555 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | | `pnpm run build` | `engine.mjs` ?2.8 MB + `sbom.cdx.json` + `sbom.spdx.json` (6 runtime deps) | | `pnpm run validate:skill` | 40 / 40 (incl. scripts/ no-stray-files guard + CycloneDX/SPDX SBOM checks + validate-answer/lint-reading gate-workflow doc checks) | | `pnpm run validate:reading` | 53 / 53 (topic example libraries + output-spec structure + 无术语区 firewall; offline, no LLM) | diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 364331e..d964ff2 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -59,7 +59,7 @@ the identical table in [STATUS.md](./STATUS.md) ("Commands & results"). Do not h resolve a disagreement; re-run the suite and copy the actual count. `pnpm run check:doc-counts` re-runs the suite and fails if either doc's `N tests / M files` count drifts from the real run. -- Typecheck: clean. Tests: **544 tests / 31 files ?all passing**. The Western provider +- Typecheck: clean. Tests: **555 tests / 31 files ?all passing**. The Western provider (astronomy-engine, VSOP87 + NOVAS) passes the ADR-0003 ??gate two ways: wrapper-consistency (vs astronomy-engine's own output) plus an **independent JPL Horizons golden** (10 bodies × 3 technical epochs fetched from the NASA/JPL Horizons service, query recorded in diff --git a/tools/lib/bundle-closure.test.ts b/tools/lib/bundle-closure.test.ts index 5c50208..b5476c4 100644 --- a/tools/lib/bundle-closure.test.ts +++ b/tools/lib/bundle-closure.test.ts @@ -380,6 +380,21 @@ describe('bundle-closure: spdxKind / cycloneDxLicenses', () => { it('nested parens (A OR (B AND C)) -> expression (valid)', () => { expect(spdxKind('(A OR (B AND C))')).toBe('expression'); }); + // --- P1-fix-4: WITH position restriction --- + it('WITH on compound expression -> throw', () => { + expect(() => spdxKind('(MIT OR Apache-2.0) WITH Classpath-exception-2.0')).toThrow( + /WITH can only follow a bare SPDX id/, + ); + }); + it('WITH exception is parens -> throw', () => { + expect(() => spdxKind('MIT WITH (A OR B)')).toThrow(/SPDX parse error/); + }); + it('double WITH -> throw', () => { + expect(() => spdxKind('MIT WITH WITH X')).toThrow(/SPDX parse error/); + }); + it('bare id WITH exception then OR continuation -> expression (valid)', () => { + expect(spdxKind('MIT WITH Classpath-exception-2.0 OR Apache-2.0')).toBe('expression'); + }); it('cycloneDxLicenses: id form -> license.id entry', () => { expect(cycloneDxLicenses('MIT')).toEqual([{ license: { id: 'MIT' } }]); }); diff --git a/tools/lib/bundle-closure.ts b/tools/lib/bundle-closure.ts index 2c040df..0969507 100644 --- a/tools/lib/bundle-closure.ts +++ b/tools/lib/bundle-closure.ts @@ -221,11 +221,18 @@ export function spdxKind(license: string): 'id' | 'expression' { } function parseTerm(): void { - parseAtom(); + const atomKind = parseAtom(); // Optional WITH clause (a license exception like `GPL-2.0 WITH Classpath-exception-2.0`). + // Per SPDX spec, WITH can only follow a BARE license id, not a compound + // (parenthesized) expression. `(MIT OR Apache-2.0) WITH X` is invalid. if (pos < tokens.length && peek() === 'WITH') { + if (atomKind !== 'id') { + throw new Error( + `SPDX parse error in "${license}": WITH can only follow a bare SPDX id, not a compound expression`, + ); + } advance(); // consume WITH - // The WITH operand must be a single SPDX id (not a sub-expression). + // The WITH operand must be a single SPDX id (not a sub-expression or parens). const ex = advance(); if (!SPDX_ID_RE.test(ex)) { throw new Error( @@ -235,7 +242,7 @@ export function spdxKind(license: string): 'id' | 'expression' { } } - function parseAtom(): void { + function parseAtom(): 'id' | 'paren' { const tok = advance(); if (tok === '(') { if (peek() === ')') { @@ -246,7 +253,7 @@ export function spdxKind(license: string): 'id' | 'expression' { if (close !== ')') { throw new Error(`SPDX parse error in "${license}": expected ')' but got "${close}"`); } - return; + return 'paren'; } if (tok === ')') { throw new Error(`SPDX parse error in "${license}": unexpected ')'`); @@ -259,7 +266,7 @@ export function spdxKind(license: string): 'id' | 'expression' { if (!SPDX_ID_RE.test(tok)) { throw new Error(`SPDX parse error in "${license}": "${tok}" is not a valid SPDX id`); } - // Valid id — consumed. + return 'id'; } parseExpr(); diff --git a/tools/validate-sbom.test.ts b/tools/validate-sbom.test.ts index 06c26b0..2a96a8b 100644 --- a/tools/validate-sbom.test.ts +++ b/tools/validate-sbom.test.ts @@ -440,4 +440,181 @@ describe('validate-sbom (offline synthetic)', () => { const failed = failedNames(t); expect(failed).toContain('spdx no duplicate third-party package: bar'); }); + + // --- P1-fix-4: structural completeness (missing identity fields) ----------- + + it('19. CycloneDX library component missing name -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = JSON.parse(ser(cdx([{ name: 'foo', version: '1.0.0' }]))); + // Inject a nameless library component. + cdxObj.components.push({ + type: 'library', + version: '1.0.0', + purl: 'pkg:npm/x@1.0.0', + licenses: [{ license: { id: 'MIT' } }], + }); + const spdxObj = spdx([{ name: 'foo', version: '1.0.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has name'))).toBe(true); + }); + + it('20. CycloneDX library component missing version -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = JSON.parse(ser(cdx([{ name: 'foo', version: '1.0.0' }]))); + cdxObj.components.push({ + type: 'library', + name: 'bar', + purl: 'pkg:npm/bar@1.0.0', + licenses: [{ license: { id: 'MIT' } }], + }); + const spdxObj = spdx([{ name: 'foo', version: '1.0.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has version'))).toBe(true); + }); + + it('21. CycloneDX library component missing purl -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = JSON.parse(ser(cdx([{ name: 'foo', version: '1.0.0' }]))); + cdxObj.components.push({ + type: 'library', + name: 'bar', + version: '1.0.0', + licenses: [{ license: { id: 'MIT' } }], + }); + const spdxObj = spdx([{ name: 'foo', version: '1.0.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has purl'))).toBe(true); + }); + + it('22. CycloneDX library component missing license -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = JSON.parse(ser(cdx([{ name: 'foo', version: '1.0.0' }]))); + cdxObj.components.push({ + type: 'library', + name: 'bar', + version: '1.0.0', + purl: 'pkg:npm/bar@1.0.0', + }); + const spdxObj = spdx([{ name: 'foo', version: '1.0.0' }]); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(spdxObj), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has license'))).toBe(true); + }); + + it('23. SPDX third-party package missing versionInfo -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = cdx([{ name: 'foo', version: '1.0.0' }]); + const baseSpdx = JSON.parse(ser(spdx([{ name: 'foo', version: '1.0.0' }]))) as any; + baseSpdx.packages.push({ + name: 'bar', + SPDXID: 'SPDXRef-Package-bar', + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: 'MIT', + licenseDeclared: 'MIT', + externalRefs: [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: 'pkg:npm/bar@1.0.0', + }, + ], + }); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(baseSpdx), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has versionInfo'))).toBe(true); + }); + + it('24. SPDX third-party package missing purl -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = cdx([{ name: 'foo', version: '1.0.0' }]); + const baseSpdx = JSON.parse(ser(spdx([{ name: 'foo', version: '1.0.0' }]))) as any; + baseSpdx.packages.push({ + name: 'bar', + SPDXID: 'SPDXRef-Package-bar', + versionInfo: '1.0.0', + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: 'MIT', + licenseDeclared: 'MIT', + }); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(baseSpdx), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has purl'))).toBe(true); + }); + + it('25. SPDX third-party package missing license -> FAIL', () => { + const closure = [pkg('foo', '1.0.0')]; + const cdxObj = cdx([{ name: 'foo', version: '1.0.0' }]); + const baseSpdx = JSON.parse(ser(spdx([{ name: 'foo', version: '1.0.0' }]))) as any; + baseSpdx.packages.push({ + name: 'bar', + SPDXID: 'SPDXRef-Package-bar', + versionInfo: '1.0.0', + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + externalRefs: [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: 'pkg:npm/bar@1.0.0', + }, + ], + }); + const t: ValidateSbomInputs = { + closure, + cdxText: ser(cdxObj), + spdxText: ser(baseSpdx), + exceptions: [], + cdxPath: '/m/c', + spdxPath: '/m/s', + }; + const failed = failedNames(t); + expect(failed.some((n) => n.includes('has license'))).toBe(true); + }); }); diff --git a/tools/validate-sbom.ts b/tools/validate-sbom.ts index f06103e..694cb89 100644 --- a/tools/validate-sbom.ts +++ b/tools/validate-sbom.ts @@ -253,6 +253,41 @@ export function validateSbom(inputs: ValidateSbomInputs): Check[] { add(`spdx no duplicate third-party package: ${d}`, false, 'appears more than once'); } + // 0b. Structural completeness: every CycloneDX library component must carry + // all required identity fields. A component that lacks any of these was + // silently skipped by the old extract-and-Map logic; now it's a hard FAIL. + for (const [i, c] of (cdx.components ?? []).entries()) { + if (c.type !== 'library') continue; + if (typeof c.name !== 'string' || c.name === '') + add(`cyclonedx component[${i}] has name`, false, 'missing or empty'); + if (typeof c.version !== 'string' || c.version === '') + add(`cyclonedx component[${i}] has version`, false, 'missing or empty'); + if (typeof c.purl !== 'string' || c.purl === '') + add(`cyclonedx component[${i}] has purl`, false, 'missing or empty'); + const lic = c.licenses?.[0]; + if (!lic || (lic.license?.id === undefined && lic.expression === undefined)) + add(`cyclonedx component[${i}] has license`, false, 'no license.id or expression'); + } + + // 0c. Structural completeness for SPDX third-party packages. + for (const [i, p] of (spdx.packages ?? []).entries()) { + if (typeof p.name !== 'string') { + add(`spdx package[${i}] has name`, false, 'missing'); + continue; + } + if (p.name === APP_NAME) continue; + if (!p.versionInfo) + add(`spdx package[${i}] "${p.name}" has versionInfo`, false, 'missing or empty'); + const purl = p.externalRefs?.find((r) => r.referenceType === 'purl')?.referenceLocator; + if (!purl) add(`spdx package[${i}] "${p.name}" has purl`, false, 'no purl in externalRefs'); + if (!p.licenseConcluded && !p.licenseDeclared) + add( + `spdx package[${i}] "${p.name}" has license`, + false, + 'no licenseConcluded or licenseDeclared', + ); + } + // 1. Every truth-set package must exist in both SBOMs with matching fields. // Purls are checked against the CANONICAL form recomputed here from // (name, version) via the shared npmPurl — not merely string-equality From ef35cfc358cf40b605ffb71decf23c4994290e5f Mon Sep 17 00:00:00 2001 From: Jowitt13 <51783594+Jowitt13@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:03:37 +0800 Subject: [PATCH 6/6] fix(sbom): normalise backslash in metafile paths before path.join (Linux CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeBundleClosure passed the raw metafile key directly to path.join when constructing the absolute path for resolvePackageRoot. On Linux, backslash characters in the key (e.g. node_modules\\foo\\packages\\x.js) are treated as literal filename characters, not separators, causing dirname to skip directly to the root and resolvePackageRoot to throw 'could not resolve package root'. Windows worked because its path module accepts both \\ and / as separators. Fix: replace \\ with / in the raw key before handing it to isAbsolute/join. The original raw string is preserved for error messages, the ignored arrays, and the deterministic inputs field in BundlePackage. Added test #22: a mixed-separator input (forward + backslash in the same key) that exercises the full resolvePackageRoot walk — would throw on Linux without this fix. Count 555->556/31. SBOM SHA-256 unchanged. verify:cloud green. build x2 zero-drift. git diff --check clean. --- docs/STATUS.md | 2 +- docs/VALIDATION.md | 2 +- tools/lib/bundle-closure.test.ts | 17 +++++++++++++++++ tools/lib/bundle-closure.ts | 7 ++++++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index cc1bd64..ea1a8f4 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -179,7 +179,7 @@ never by hand. | Command | Result | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pnpm run typecheck` | clean (tsc strict over packages, tools, tests) | -| `pnpm run test` | 555 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | +| `pnpm run test` | 556 tests / 31 files ?all passing (all systems + JPL Horizons 独立 golden + interpret + 吉凶 + 合婚 + reading-lint/空话/重复/越界 + validate-answer v2 结构与措辞门禁(约束引用事实豁免+全可见文本安全扫?资源上限+有界解析入口,非语义正确性证明) + western-rules/ziwei-rules 语义规则 + 版本迁移/回滚/目标白名?+ PII 隐私护栏 green) | | `pnpm run build` | `engine.mjs` ?2.8 MB + `sbom.cdx.json` + `sbom.spdx.json` (6 runtime deps) | | `pnpm run validate:skill` | 40 / 40 (incl. scripts/ no-stray-files guard + CycloneDX/SPDX SBOM checks + validate-answer/lint-reading gate-workflow doc checks) | | `pnpm run validate:reading` | 53 / 53 (topic example libraries + output-spec structure + 无术语区 firewall; offline, no LLM) | diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index d964ff2..dd62121 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -59,7 +59,7 @@ the identical table in [STATUS.md](./STATUS.md) ("Commands & results"). Do not h resolve a disagreement; re-run the suite and copy the actual count. `pnpm run check:doc-counts` re-runs the suite and fails if either doc's `N tests / M files` count drifts from the real run. -- Typecheck: clean. Tests: **555 tests / 31 files ?all passing**. The Western provider +- Typecheck: clean. Tests: **556 tests / 31 files ?all passing**. The Western provider (astronomy-engine, VSOP87 + NOVAS) passes the ADR-0003 ??gate two ways: wrapper-consistency (vs astronomy-engine's own output) plus an **independent JPL Horizons golden** (10 bodies × 3 technical epochs fetched from the NASA/JPL Horizons service, query recorded in diff --git a/tools/lib/bundle-closure.test.ts b/tools/lib/bundle-closure.test.ts index b5476c4..610695e 100644 --- a/tools/lib/bundle-closure.test.ts +++ b/tools/lib/bundle-closure.test.ts @@ -312,6 +312,23 @@ describe('bundle-closure: computeBundleClosure', () => { expect(r.packages.map((p) => `${p.name}@${p.version}`)).toEqual(['backslashy@0.0.9']); expect(r.ignored.repoInternal).toEqual([]); }); + + it('22. mixed-separator input resolves via file-system path (not just classification)', () => { + // Forward + backward slashes mixed in the same key. Must survive both + // classifyInput (segment logic) AND resolvePackageRoot (dirname walk). + // If the backslash-to-forward-slash normalisation only happened in + // classifyInput but NOT in the file-system resolution stage, this test + // would throw on Linux. + const dirMixed = nm('node_modules/mixedpkg'); + const map = new Map([ + [dirMixed, { name: 'mixedpkg', version: '2.0.0', license: 'MIT' }], + ]); + const r = computeBundleClosure( + { inputs: { 'node_modules/mixedpkg\\src/index.js': {} } }, + { root: ROOT, readPackageJson: makeReader(map) }, + ); + expect(r.packages[0]!.name).toBe('mixedpkg'); + }); }); describe('bundle-closure: npmPurl (canonical purl)', () => { diff --git a/tools/lib/bundle-closure.ts b/tools/lib/bundle-closure.ts index 0969507..7fd57c9 100644 --- a/tools/lib/bundle-closure.ts +++ b/tools/lib/bundle-closure.ts @@ -430,7 +430,12 @@ export function computeBundleClosure( throw new Error(`could not classify metafile input: ${raw}`); } // Third-party. Resolve to an absolute path relative to root, then walk up. - const abs = isAbsolute(raw) ? raw : join(opts.root, raw); + // Normalise the raw metafile key to forward slashes before handing it to + // path.join/path.isAbsolute, because Linux does not interpret `\` as a + // separator. The original `raw` is preserved for error messages, the + // `ignored` arrays, and the deterministic `inputs` field. + const fsPath = raw.replace(/\\/g, '/'); + const abs = isAbsolute(fsPath) ? fsPath : join(opts.root, fsPath); const { dir, json } = resolvePackageRoot(abs, { root: opts.root, readPackageJson,