diff --git a/.claude/settings.json b/.claude/settings.json index 8a4b4607..2d492372 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -67,6 +67,7 @@ "SendMessage" ], "deny": [ + "Bash(gh release delete:*)", "Bash(npm publish:*)", "Bash(pnpm publish:*)", "Bash(yarn publish:*)" diff --git a/.gitignore b/.gitignore index d872b8b0..c332454f 100644 --- a/.gitignore +++ b/.gitignore @@ -207,6 +207,18 @@ pnpm-debug.log /emitters/dotnet-tool/obj/ /emitters/dotnet-tool/publish/ +# JVM build-tool + IDE residue. The conformance fixtures under test/ run a REAL +# Maven/Gradle invocation inside the fixture dir, so the tool writes its own +# caches next to the tracked build file; the VS Code Java/Gradle extensions then +# drop Eclipse project metadata beside them. Unanchored so a new fixture or +# emitter is covered without another entry. +**/.gradle/ +**/target/ +**/.settings/ +.classpath +.factorypath +.project + # # Fleet-pack untrack set — managed by scripts/repo/bootstrap/fleet.mjs. # REGENERATED from the release-bundle manifest on every hydrate; stale @@ -232,6 +244,12 @@ pnpm-debug.log .claude/commands/fleet/update-pricing.md .claude/commands/fleet/update-security.md .claude/hooks/fleet/_dist/fleet-pack.cjs +.claude/hooks/fleet/_dist/launchers/dispatch-launcher-darwin-arm64 +.claude/hooks/fleet/_dist/launchers/dispatch-launcher-darwin-x64 +.claude/hooks/fleet/_dist/launchers/dispatch-launcher-linux-arm64 +.claude/hooks/fleet/_dist/launchers/dispatch-launcher-linux-x64 +.claude/hooks/fleet/_dist/launchers/dispatch-launcher-win32-ia32.exe +.claude/hooks/fleet/_dist/launchers/dispatch-launcher-win32-x64.exe .claude/hooks/fleet/_shared/.clangd .claude/hooks/fleet/_shared/README.md .claude/hooks/fleet/_shared/active-edits-ledger.mts @@ -348,6 +366,8 @@ pnpm-debug.log .claude/hooks/fleet/_shared/verdict.mts .claude/hooks/fleet/_shared/waiting-discipline.mts .claude/hooks/fleet/_shared/wheelhouse-root.mts +.claude/hooks/fleet/account-snapshot-recorder/README.md +.claude/hooks/fleet/account-snapshot-recorder/index.mts .claude/hooks/fleet/actionlint-on-workflow-edit/README.md .claude/hooks/fleet/actionlint-on-workflow-edit/index.mts .claude/hooks/fleet/actionlint-on-workflow-edit/package.json @@ -728,6 +748,12 @@ pnpm-debug.log .claude/hooks/fleet/minimum-release-age-guard/index.mts .claude/hooks/fleet/minimum-release-age-guard/package.json .claude/hooks/fleet/minimum-release-age-guard/tsconfig.json +.claude/hooks/fleet/model-policy-guard/README.md +.claude/hooks/fleet/model-policy-guard/index.mts +.claude/hooks/fleet/model-policy-guard/model-policy.mts +.claude/hooks/fleet/model-policy-guard/settings-layers.mts +.claude/hooks/fleet/model-spawn-policy-guard/README.md +.claude/hooks/fleet/model-spawn-policy-guard/index.mts .claude/hooks/fleet/module-noun-name-guard/README.md .claude/hooks/fleet/module-noun-name-guard/index.mts .claude/hooks/fleet/module-noun-name-guard/package.json @@ -1216,6 +1242,7 @@ pnpm-debug.log .claude/hooks/fleet/reply-prose-nudge/index.mts .claude/hooks/fleet/reply-prose-nudge/package.json .claude/hooks/fleet/reply-prose-nudge/tsconfig.json +.claude/hooks/fleet/reply-ref-link-guard/index.mts .claude/hooks/fleet/repo-map-refresh/index.mts .claude/hooks/fleet/report-location-guard/README.md .claude/hooks/fleet/report-location-guard/index.mts @@ -1321,6 +1348,8 @@ pnpm-debug.log .claude/hooks/fleet/soak-pin-needs-annotation-guard/index.mts .claude/hooks/fleet/soak-pin-needs-annotation-guard/package.json .claude/hooks/fleet/soak-pin-needs-annotation-guard/tsconfig.json +.claude/hooks/fleet/spend-warning-nudge/README.md +.claude/hooks/fleet/spend-warning-nudge/index.mts .claude/hooks/fleet/squash-freeze-boundary-guard/index.mts .claude/hooks/fleet/squash-freeze-boundary-guard/package.json .claude/hooks/fleet/squash-freeze-boundary-guard/tsconfig.json @@ -1568,6 +1597,8 @@ pnpm-debug.log .claude/skills/fleet/migrating-rule-packs/SKILL.md .claude/skills/fleet/migrating-rule-packs/lib/run-migration-worktree.mts .claude/skills/fleet/migrating-rule-packs/lib/run-migration.mts +.claude/skills/fleet/mocking-vendor-screens/SKILL.md +.claude/skills/fleet/mocking-vendor-screens/npm-mockup.mts .claude/skills/fleet/opening-pr/SKILL.md .claude/skills/fleet/optimizing-compiler-performance/SKILL.md .claude/skills/fleet/optimizing-cpp-performance/SKILL.md @@ -1606,6 +1637,9 @@ pnpm-debug.log .claude/skills/fleet/prose/references/phrases.md .claude/skills/fleet/prose/references/structures.md .claude/skills/fleet/pushing/SKILL.md +.claude/skills/fleet/recording-ui-walkthroughs/SKILL.md +.claude/skills/fleet/recording-ui-walkthroughs/profiles/npm-create-token.json +.claude/skills/fleet/recording-ui-walkthroughs/record-walkthrough.mts .claude/skills/fleet/refreshing-history/SKILL.md .claude/skills/fleet/refreshing-history/run.mts .claude/skills/fleet/releasing-a-package/SKILL.md @@ -1946,6 +1980,7 @@ pnpm-debug.log .config/fleet/playwright/agent-banner-shield.svg .config/fleet/playwright/agent-banner.js .config/fleet/playwright/challenge-screen.js +.config/fleet/playwright/operator-note.js .config/fleet/pnpm-workspace.fleet.yaml .config/fleet/rolldown/hook-bundle-excluded.config.mts .config/fleet/rolldown/hook-bundle-snapshot.config.mts @@ -2102,6 +2137,7 @@ docs/agents.md/fleet/release-pins-are-canonical.md docs/agents.md/fleet/release-tag-escape-hatch.md docs/agents.md/fleet/release-vs-cascade.md docs/agents.md/fleet/repo-map.md +docs/agents.md/fleet/reporting-in-ste100.md docs/agents.md/fleet/researching-recency.md docs/agents.md/fleet/runtime-feature-floors.md docs/agents.md/fleet/runtime-state-and-caches.md @@ -2158,6 +2194,8 @@ scripts/fleet/_shared/check-steps-hooks.mts scripts/fleet/_shared/check-steps-paths.mts scripts/fleet/_shared/check-steps-release.mts scripts/fleet/_shared/check-steps.mts +scripts/fleet/_shared/claude-usage-breakdowns.mts +scripts/fleet/_shared/claude-usage.mts scripts/fleet/_shared/dispatch-scan.mts scripts/fleet/_shared/fixer-lock.mts scripts/fleet/_shared/fixture-names.mts @@ -2170,9 +2208,11 @@ scripts/fleet/_shared/git-porcelain.mts scripts/fleet/_shared/github-raw-url.mts scripts/fleet/_shared/github-tracked-surface.mts scripts/fleet/_shared/gitmodules.mts +scripts/fleet/_shared/go-workspaces.mts scripts/fleet/_shared/hook-wiring.mts scripts/fleet/_shared/human-gate.mts scripts/fleet/_shared/is-main-module.mts +scripts/fleet/_shared/launcher-variants.mts scripts/fleet/_shared/lifecycle-scripts.mts scripts/fleet/_shared/lint-runners.mts scripts/fleet/_shared/managed-ruleset-identity.mts @@ -2187,6 +2227,7 @@ scripts/fleet/_shared/pack-structure.mts scripts/fleet/_shared/pinned-ref.mts scripts/fleet/_shared/playwright-law.mts scripts/fleet/_shared/pnpm-lockfile.mts +scripts/fleet/_shared/poll-with-decay.mts scripts/fleet/_shared/pr-body-law.mts scripts/fleet/_shared/process-lifecycle.mts scripts/fleet/_shared/prose-em-dash.mts @@ -2205,6 +2246,7 @@ scripts/fleet/_shared/scope-flags.mts scripts/fleet/_shared/security-posture-law.mts scripts/fleet/_shared/security-posture-probe.mts scripts/fleet/_shared/spawn-env-scan.mts +scripts/fleet/_shared/swift-targets.mts scripts/fleet/_shared/tar-executable.mts scripts/fleet/_shared/template-payload-scope.mts scripts/fleet/_shared/terminal-link.mts @@ -2232,6 +2274,8 @@ scripts/fleet/analyze-range-consolidation/ecosystems/npm.mts scripts/fleet/analyze-range-consolidation/override-audit-report.mts scripts/fleet/analyze-range-consolidation/override-audit.mts scripts/fleet/analyze-range-consolidation/verdict.mts +scripts/fleet/apple-notarize.mts +scripts/fleet/apple-sign.mts scripts/fleet/audit-transcript.mts scripts/fleet/auditing-history/lib/patch-id.mts scripts/fleet/auditing-history/lib/types.mts @@ -2248,10 +2292,6 @@ scripts/fleet/backup-branches/unique-content.mts scripts/fleet/build-hook-bundle.mts scripts/fleet/build-hook-snapshot.mts scripts/fleet/build-infra/lib/external-tools-schema.json -scripts/fleet/build-infra/lib/release-checksums/consumer.mts -scripts/fleet/build-infra/lib/release-checksums/core.mts -scripts/fleet/build-infra/lib/release-checksums/producer.mts -scripts/fleet/build-infra/release-assets.schema.json scripts/fleet/build-oxlint-bundle.mts scripts/fleet/build-snapshot-launcher.mts scripts/fleet/bump.mts @@ -2267,6 +2307,7 @@ scripts/fleet/cache/tar-archive.mts scripts/fleet/cache/twirp.mts scripts/fleet/cargo-publish.mts scripts/fleet/check.mts +scripts/fleet/check/account-identity-is-not-committed.mts scripts/fleet/check/action-pins-are-current.mts scripts/fleet/check/action-ports-are-lock-stepped.mts scripts/fleet/check/actions-are-segmented.mts @@ -2303,6 +2344,7 @@ scripts/fleet/check/claude-md-citations-resolve.mts scripts/fleet/check/claude-md-repo-section-is-a-bullet-index.mts scripts/fleet/check/claude-md-rules-are-enforced.mts scripts/fleet/check/claude-md-rules-are-informative.mts +scripts/fleet/check/claude-settings-fleet-markers-are-short.mts scripts/fleet/check/comment-markers-are-honeypot-inert.mts scripts/fleet/check/commits-are-signed.mts scripts/fleet/check/commits-have-no-ai-attribution.mts @@ -2429,6 +2471,7 @@ scripts/fleet/check/pnpm-run-flags-have-no-bare-dash.mts scripts/fleet/check/pr-refs-in-docs-are-linked.mts scripts/fleet/check/precommit-steps-are-bounded.mts scripts/fleet/check/prettierignore-globs-are-anchored.mts +scripts/fleet/check/priced-models-cover-observed-usage.mts scripts/fleet/check/pricing-data-is-current.mts scripts/fleet/check/private-packages-are-unpublishable.mts scripts/fleet/check/private-paths-are-absent.mts @@ -2499,6 +2542,7 @@ scripts/fleet/check/upstream-contracts-are-current.mts scripts/fleet/check/upstream-gitlinks-are-absent.mts scripts/fleet/check/upstream-submodules-are-release-tagged.mts scripts/fleet/check/upstream-submodules-are-shallow-single-branch.mts +scripts/fleet/check/usage-dedup-key-is-sound.mts scripts/fleet/check/uv-lockfiles-are-current.mts scripts/fleet/check/version-derivation-jobs-have-tags.mts scripts/fleet/check/version-is-not-ahead-of-published.mts @@ -2551,7 +2595,6 @@ scripts/fleet/cover/runner.mts scripts/fleet/cover/rust-lane.mts scripts/fleet/cover/scratch-isolation.mts scripts/fleet/crate-release-sha.mts -scripts/fleet/create-release.mts scripts/fleet/cross-cli/fleet-fork-detect.mts scripts/fleet/cross-cli/pretooluse-hook.mts scripts/fleet/depot-ci.mts @@ -2571,8 +2614,15 @@ scripts/fleet/external-tools/schema.mts scripts/fleet/external-tools/show.mts scripts/fleet/external-tools/update.mts scripts/fleet/fetch-fleet-pack.mts +scripts/fleet/fix-cpp.mts +scripts/fleet/fix-go.mts +scripts/fleet/fix-rust.mts +scripts/fleet/fix-swift.mts scripts/fleet/fix.mts +scripts/fleet/fmt-cpp.mts +scripts/fleet/fmt-go.mts scripts/fleet/fmt-rust.mts +scripts/fleet/fmt-swift.mts scripts/fleet/format.mts scripts/fleet/fsync-dist.mts scripts/fleet/gen/agents-skills-mirror.mts @@ -2656,12 +2706,15 @@ scripts/fleet/lib/telemetry-scan.mts scripts/fleet/lib/verify-release-hashes.mts scripts/fleet/lib/workspace-yaml.mts scripts/fleet/lint-actions.mts +scripts/fleet/lint-cpp.mts scripts/fleet/lint-github-settings.mts scripts/fleet/lint-github-settings/detect.mts scripts/fleet/lint-github-settings/evaluate.mts scripts/fleet/lint-github-settings/types.mts +scripts/fleet/lint-go.mts scripts/fleet/lint-pr-comment.mts scripts/fleet/lint-rust.mts +scripts/fleet/lint-swift.mts scripts/fleet/lint.mts scripts/fleet/lockstep-emit-mirror-globs.mts scripts/fleet/lockstep-emit-schema.mts @@ -2703,6 +2756,10 @@ scripts/fleet/prune-actions-caches.mts scripts/fleet/prune-workflow-runs.mts scripts/fleet/publish-pipeline.mts scripts/fleet/publish-shared.mts +scripts/fleet/registry-infra/apple/developer-id-cert.mts +scripts/fleet/registry-infra/apple/developer-id-page.mts +scripts/fleet/registry-infra/apple/developer-id-plan.mts +scripts/fleet/registry-infra/apple/keychain-csr.mts scripts/fleet/registry-infra/cargo/approve.mts scripts/fleet/registry-infra/cargo/bump.mts scripts/fleet/registry-infra/cargo/placeholder.mts @@ -2782,6 +2839,7 @@ scripts/fleet/release-pipeline/staged-commit.mts scripts/fleet/release-pipeline/stages.mts scripts/fleet/release-pipeline/state.mts scripts/fleet/release-pipeline/summary.mts +scripts/fleet/report-claude-usage.mts scripts/fleet/researching-recency/cli.mts scripts/fleet/researching-recency/lib/dedupe.mts scripts/fleet/researching-recency/lib/fetch.mts @@ -2871,6 +2929,7 @@ scripts/fleet/socket-wheelhouse-schema/policy.mts scripts/fleet/socket-wheelhouse-schema/testing.mts scripts/fleet/socket-wheelhouse-schema/tooling.mts scripts/fleet/source-pricing-feed.mts +scripts/fleet/spend-statusline.mts scripts/fleet/strip-ai-tags.mts scripts/fleet/sync-gh-aw-action-pins.mts scripts/fleet/sync-oxlint-rules.mts diff --git a/CLAUDE.md b/CLAUDE.md index 3650bbdf..eb631699 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,8 +153,8 @@ - 🚨 `src/contract/` is a wire format a second implementation parses with a `.strict()` schema, so an additive field is a coordinated release, never a local edit. [`contract`](docs/agents.md/repo/contract.md) -- 🚨 The sidecar's `classifier` serializes as an explicit JSON `null`; the fleet's - `undefined`-over-`null` preference stops at the wire. +- 🚨 The sidecar is keyed by absolute `.socket.facts.json` path: the key IS the + per-subproject reachability scope, and there is NO cross-reactor dedup. [`contract`](docs/agents.md/repo/contract.md) - 🚨 The conformance oracle diffs emitted facts against the BUILD's own report, never a golden file. [`ground-truth-oracle`](docs/agents.md/repo/ground-truth-oracle.md) - 🚨 Only `extra` is tolerated in that diff; `missing`, `version-mismatch`, and a missing edge never are. [`ground-truth-oracle`](docs/agents.md/repo/ground-truth-oracle.md) diff --git a/docs/agents.md/repo/contract.md b/docs/agents.md/repo/contract.md index 79a5f257..f9570040 100644 --- a/docs/agents.md/repo/contract.md +++ b/docs/agents.md/repo/contract.md @@ -9,10 +9,10 @@ library rather than a socket-cli internal, so treat them as published shapes. The reachability consumer hand-maintains its own copies: -- `coana-package-manager/packages/shared-types/src/socket-facts-schema.ts` - a - parallel type declaration of the SBOM side. -- `.../java/sidecar-artifact-paths.ts` - a zod schema for the sidecar, with - `.strict()` on the component object. +- a parallel type declaration of the SBOM side, in its shared-types package. +- `sidecar-artifact-paths.ts` in its JVM reachability analyzer - a zod schema for + the sidecar: a record keyed by facts-file path, with `.strict()` on both the + component and the project object. Two hand-maintained copies of one format drift, and the drift is silent until a scan produces the wrong answer. What this package exports is a superset the @@ -25,20 +25,15 @@ already reimplements. When a coordinate is missing from the sidecar, the consumer does not skip it and does not downgrade its vulnerabilities to a precomputed result. It resolves the coordinate itself, best-effort: local caches first, then -`mvn -Dtransitive=false dependency:get`, then HTTP. The fallback lives in -`coana-package-manager/packages/reachability-analyzers/src/whole-program-code-aware-vulnerability-scanner/java/java-code-aware-vulnerability-scanner.ts:807-826`, -calling the `resolveArtifact` helper at line 710 of the same file. +`mvn -Dtransitive=false dependency:get`, then HTTP. The fallback lives in the +consumer's JVM reachability scanner, in its own artifact-resolution helper. The history is worth knowing, because the short-lived behavior is the one people -remember. Coana's -[#2292](https://github.com/coana-tech/coana-package-manager/pull/2292) -(`548637bbc`, 2026-06-30) landed the sidecar consumer with a hard short-circuit: -uncovered meant unresolved. -[#2295](https://github.com/coana-tech/coana-package-manager/pull/2295) -(`5d3056a1b`, 2026-07-01) relaxed it the next day, because reachability is not -scoped per project yet, so a scan legitimately carries artifacts from -subprojects outside the sidecar's build root. The pinned 15.9.5 contains the -relaxed behavior. +remember. The consumer first landed the sidecar with a hard short-circuit on +2026-06-30: uncovered meant unresolved. It relaxed that the next day, because +reachability is not scoped per project yet, so a scan legitimately carries +artifacts from subprojects outside the sidecar's build root. The version pinned +today carries the relaxed behavior. The consequence is the load-bearing part. **The sidecar is an accelerator, not an authority.** A gap does not fail the scan and does not narrow it - it silently @@ -49,41 +44,39 @@ concern worth surfacing, not a benign fallback. Nothing in the wire format signals the miss; the only evidence is a `resolvedSource` other than `sidecar` / `sidecar-no-artifact` in the consumer's debug log. -## `classifier` serializes as an explicit JSON null - -The fleet prefers `undefined` over `null` everywhere except here. The sidecar's -consumer types `classifier` as `z.string().nullable()`, and an absent key is a -different payload from an explicit `null`. `validateResolvedPathsSidecar` -therefore rejects a component whose `classifier` key is missing, even though -every other absent-optional would be fine. - ## An additive field is a coordinated release -The sidecar consumer's component schema is `.strict()`. Under a strict schema an +The sidecar consumer parses each component AND each project with a `.strict()` +schema, inside a record keyed by facts-file path. Under a strict schema an unrecognized key is not ignored - it fails the parse, and the failure is -whole-payload, not per-field. So adding **any** field to `ResolvedComponent`, -including a `schemaVersion` intended to make future additions safe, breaks every -consumer pinned to a version released before the addition. +whole-payload, not per-field. So adding **any** field to `SidecarComponentEntry` +or `SidecarProjectEntry`, including a `schemaVersion` intended to make future +additions safe, breaks every consumer pinned to a version released before the +addition. `validateResolvedPathsSidecar` enforces this from the producer side: an unknown key is a violation here, so a producer cannot emit a payload the consumer will -reject. - -### `ecosystem` is the one field added under that rule - -`ResolvedComponent.ecosystem` carries the artifact's purl type, because a -groupless NuGet id and a Maven artifactId can produce the same coordinate key -and there is no other way to tell them apart. Adding it follows the rule above -rather than escaping it: **every** reachability scan, single-ecosystem JVM ones -included, fails at the sidecar handoff until the consumer's schema accepts the -key, because the producer stamps the tag on every entry and a `.strict()` parse -rejects the whole payload rather than the one field. Releasing the consumer's -schema change first is the gate on shipping a version of this package that -emits it. - -The validator is asymmetric here on purpose: it accepts a payload with no -`ecosystem` key, because that is exactly what a sidecar written before the tag -existed looks like, and it means `maven`. Strict producer, liberal consumer. +reject. `SIDECAR_COMPONENT_FIELDS` and `SIDECAR_PROJECT_FIELDS` are sorted so +each list diffs against the consumer's own `.strict()` object at a glance. + +### The facts-file key is the scope + +The sidecar is keyed by the absolute path of the `.socket.facts.json` whose own +`projects[]`/`components[]` each bucket describes, and that key is what +per-subproject reachability reads. Two independent reactors that emit the same +purl identity cannot collide, because each is only ever looked up within its own +key. There is no cross-reactor deduplication: the same external dependency +resolved by several reactors is deliberately duplicated across all of their +`components[]`, which is simpler and safer than a shared bucket. + +### The purl `type` discriminates the ecosystem + +A groupless NuGet id and a Maven artifactId can produce the same coordinate key, +and an entry's purl `type` is what tells them apart - `maven` for +gradle/maven/sbt, `nuget` for dotnet. It is the facts entry's own `type` carried +through verbatim, so there is no narrowing and no re-derivation. An artifact's +packaging and classifier travel in `qualifiers.ext` and +`qualifiers.classifier`, the same places the SBOM puts them. ### Proposed versioning approach - not adopted diff --git a/docs/agents.md/repo/trust-boundary.md b/docs/agents.md/repo/trust-boundary.md index 10109f5a..cd8182c6 100644 --- a/docs/agents.md/repo/trust-boundary.md +++ b/docs/agents.md/repo/trust-boundary.md @@ -6,28 +6,34 @@ configuration, because that configuration is attacker-controlled. ## What the consumer owns -socket-cli's `manifest-build-trust.mts` gates `bin` and `opts` supplied by a -repository's `socket.json`. Its refusal list is build-tool integration +Vetting `bin` and `opts` before they reach this package. A consumer that reads +those values out of a repository's own configuration is reading +attacker-controlled input, and the refusal list is build-tool integration knowledge: Gradle's `-I` / `--init-script` / `--gradle-user-home` / `-Dorg.gradle.java.home`, Maven's `-Dmaven.ext.class.path` / `-s` / `-Dmaven.repo.local`, sbt's `-J` / `-Dsbt.global.base` / a bare `eval`. Every one of those re-points the build at code or at a repository the scan did not choose. -That gate stays with the consumer. Moving it here would mean the party that -sells the completeness guarantee is trusting somebody else's argv construction, -which is exactly the arrangement the #1385 reversal came out of. +That gate belongs with the consumer, not here: moving it in would mean the party +selling the completeness guarantee trusts somebody else's argv construction. + +It is an OBLIGATION on the consumer, not a guarantee this package can lean on: +whoever takes this package as a dependency owns that vetting. Note that the +dotnet tool accepts `-p:Key=Value` MSBuild global properties, and +`--property:`, which apply to the whole session — evaluation, restore, and +reading alike — so vetting has to cover that surface too. ## What this package refuses to decide `assertFactsInvocation` runs before anything is spawned and throws on: -| Field | Refused when | Why not default it | -| ------ | ------------------------------- | --------------------------------------------------------------------------------- | -| `tool` | not `gradle`, `maven`, or `sbt` | picking a tool from the directory contents is a detection policy | -| `bin` | absent, empty, or relative | a PATH lookup or a `./gradlew` probe picks an executable the consumer did not vet | -| `opts` | absent, or holding a non-string | an omitted array reads as "no options" when it may mean "the filter never ran" | -| `env` | absent, or not an object | falling back to `process.env` reintroduces the argument-injection gap below | -| `cwd` | absent, empty, or relative | `process.cwd()` resolves a different project than the caller meant | +| Field | Refused when | Why not default it | +| ------ | ----------------------------------------- | --------------------------------------------------------------------------------- | +| `tool` | not `dotnet`, `gradle`, `maven`, or `sbt` | picking a tool from the directory contents is a detection policy | +| `bin` | absent, empty, or relative | a PATH lookup or a `./gradlew` probe picks an executable the consumer did not vet | +| `opts` | absent, or holding a non-string | an omitted array reads as "no options" when it may mean "the filter never ran" | +| `env` | absent, or not an object | falling back to `process.env` reintroduces the argument-injection gap below | +| `cwd` | absent, empty, or relative | `process.cwd()` resolves a different project than the caller meant | Each failure names the field, what was wanted, and that the caller must supply it. None of them is recoverable by guessing, so none of them is guessed. diff --git a/emitters/dotnet-tool/FactsRunner.cs b/emitters/dotnet-tool/FactsRunner.cs index dee57595..417e0eae 100644 --- a/emitters/dotnet-tool/FactsRunner.cs +++ b/emitters/dotnet-tool/FactsRunner.cs @@ -45,6 +45,7 @@ public static int Run(ToolOptions opts, string sdkVersion) { private sealed class Session(ToolOptions opts, RecordsWriter records) { private readonly List _includes = ParsePatterns(opts.IncludeConfigs); private readonly List _excludes = ParsePatterns(opts.ExcludeConfigs); + private readonly List _excludePaths = ParsePatterns(opts.ExcludePaths); private readonly HashSet _scanned = new(StringComparer.Ordinal); // Restore eligibility per evaluated project, keyed by full path. Decided @@ -62,7 +63,10 @@ public void Execute() { } var graphs = EvaluateGraphs(entries); - var projectPaths = graphs.ProjectPaths; + // A wholly excluded project emits no records at all, matching the JVM + // emitters; source-file-level exclusion stays with the reachability + // analysis. + var projectPaths = graphs.ProjectPaths.Where(p => !IsExcludedPath(p)).ToList(); if (!opts.NoRestore) { // A standalone project restores only if it supports restore; a // solution restores if ANY member does (NuGet skips the rest). @@ -893,6 +897,16 @@ private bool ConfigMatches(IReadOnlyList names) { return _includes.Count == 0 || _includes.Any(p => names.Any(n => p.IsMatch(n))); } + // Mirrors SocketSupport.isExcludedPath: the root itself is never excluded, + // and each pattern already means "this dir OR its subtree". + private bool IsExcludedPath(string projectPath) { + if (_excludePaths.Count == 0) return false; + var rel = Rel(Path.GetDirectoryName(projectPath)!); + var c = rel == "." ? "" : rel.Trim('/'); + if (c.Length == 0) return false; + return _excludePaths.Any(p => p.IsMatch(c)); + } + private string Rel(string path) { var rel = Path.GetRelativePath(opts.RootDir, path).Replace('\\', '/'); return string.IsNullOrEmpty(rel) || rel == "." ? "." : rel; diff --git a/emitters/dotnet-tool/ToolOptions.cs b/emitters/dotnet-tool/ToolOptions.cs index a2b62013..45eaef8d 100644 --- a/emitters/dotnet-tool/ToolOptions.cs +++ b/emitters/dotnet-tool/ToolOptions.cs @@ -10,6 +10,8 @@ internal sealed class ToolOptions { --with-files Also emit resolved artifact/source paths. --include-configs Comma-separated anchored regex patterns for target framework names. --exclude-configs Comma-separated anchored regex patterns; applied after includes. + --exclude-paths Comma-separated anchored regex patterns for root-relative + project dirs; a matching project is skipped whole. --no-restore Skip the in-process restore (use existing restore output). --restore-timeout-sec Cancel restore after n seconds (default 900). --verbose Log progress to stderr. @@ -22,6 +24,7 @@ internal sealed class ToolOptions { public bool WithFiles; public string IncludeConfigs = ""; public string ExcludeConfigs = ""; + public string ExcludePaths = ""; public bool NoRestore; public int RestoreTimeoutSec = 900; public bool Verbose; @@ -47,6 +50,9 @@ public static ToolOptions Parse(string[] args) { case "--exclude-configs": opts.ExcludeConfigs = Next(args, ref i, arg); break; + case "--exclude-paths": + opts.ExcludePaths = Next(args, ref i, arg); + break; case "--no-restore": opts.NoRestore = true; break; diff --git a/emitters/maven-extension/src/main/java/dev/socket/facts/SocketFactsRecordsEngine.java b/emitters/maven-extension/src/main/java/dev/socket/facts/SocketFactsRecordsEngine.java index 0604cf09..92c40dda 100644 --- a/emitters/maven-extension/src/main/java/dev/socket/facts/SocketFactsRecordsEngine.java +++ b/emitters/maven-extension/src/main/java/dev/socket/facts/SocketFactsRecordsEngine.java @@ -85,7 +85,7 @@ public void run(MavenSession session, List reactor, File rootDir, for (MavenProject p : reactor) { reactorGavs.add(p.getGroupId() + ":" + p.getArtifactId() + ":" + p.getVersion()); } - List excludes = SocketSupport.parseExcludeMatchers(opts.excludePaths); + List excludes = SocketSupport.parseExcludePatterns(opts.excludePaths); List lines = new ArrayList<>(); rec(lines, "meta", "maven", mavenVersion, System.getProperty("java.version")); diff --git a/emitters/maven-extension/src/main/java/dev/socket/facts/SocketSupport.java b/emitters/maven-extension/src/main/java/dev/socket/facts/SocketSupport.java index cf919156..3959c562 100644 --- a/emitters/maven-extension/src/main/java/dev/socket/facts/SocketSupport.java +++ b/emitters/maven-extension/src/main/java/dev/socket/facts/SocketSupport.java @@ -1,16 +1,9 @@ package dev.socket.facts; import java.io.File; -import java.nio.file.FileSystems; import java.nio.file.Path; -import java.nio.file.PathMatcher; -import java.nio.file.Paths; -import java.util.ArrayDeque; import java.util.ArrayList; -import java.util.Deque; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; import java.util.regex.Pattern; /** @@ -47,69 +40,37 @@ public static String bareId(String groupId, String artifactId, String version) { } /** - * Compile a comma-separated list of {@code --exclude-paths} into glob {@link PathMatcher}s, used - * only to skip whole excluded reactor modules. Each entry variant yields the entry itself and - * {@code entry/**} so it matches the dir and its subtree (same expansion as the SCA ignore path). - * A trailing {@code /**} is stripped first, so a user-written {@code dir/**} still excludes the - * {@code dir} directory itself, not only its contents. Standard glob semantics (anchored to the - * scan root, matching the CLI flag): {@code x} is root-level; {@code **}{@code /x} matches at any - * depth. Mirrors the gradle/sbt producers. + * Compile a comma-separated list of PRE-COMPILED {@code --exclude-paths} regex pattern sources + * into {@link Pattern}s, used only to skip whole excluded reactor modules. This package compiles the + * user-facing globs in {@code src/run/exclude-paths-glob.mts} (the single glob implementation, tested in + * CI); this only {@code Pattern.compile()}s what it receives. A pattern that doesn't compile is + * dropped, never thrown: this package emits a dialect-portable subset, so this only guards against a + * broken transport. */ - public static List parseExcludeMatchers(String csv) { - List out = new ArrayList<>(); + public static List parseExcludePatterns(String csv) { + List out = new ArrayList<>(); if (csv == null || csv.trim().isEmpty()) return out; for (String raw : csv.split(",")) { - String g = raw.trim().replace("\\", "/"); - while (g.startsWith("/")) g = g.substring(1); - while (g.endsWith("/")) g = g.substring(0, g.length() - 1); - while (g.endsWith("/**")) { - g = g.substring(0, g.length() - 3); - while (g.endsWith("/")) g = g.substring(0, g.length() - 1); - } - if (g.isEmpty()) continue; - for (String v : zeroDepthVariants(g)) { - out.add(FileSystems.getDefault().getPathMatcher("glob:" + v)); - out.add(FileSystems.getDefault().getPathMatcher("glob:" + v + "/**")); - } - } - return out; - } - - /** - * NIO glob requires a slash-adjacent {@code **} to consume at least one path segment, but the - * CLI's micromatch lets it match zero ({@code **}{@code /x} matches root-level {@code x}). Emit - * every variant with {@code **}{@code /} occurrences dropped so both semantics hold. - */ - private static Set zeroDepthVariants(String glob) { - Set out = new LinkedHashSet<>(); - Deque work = new ArrayDeque<>(); - work.add(glob); - while (!work.isEmpty()) { - String cur = work.poll(); - if (!out.add(cur)) continue; - int idx = cur.indexOf("**/"); - while (idx >= 0) { - if (idx == 0 || cur.charAt(idx - 1) == '/') { - String collapsed = cur.substring(0, idx) + cur.substring(idx + 3); - if (!collapsed.isEmpty()) work.add(collapsed); - } - idx = cur.indexOf("**/", idx + 1); + String p = raw.trim(); + if (p.isEmpty()) continue; + try { + out.add(Pattern.compile(p)); + } catch (java.util.regex.PatternSyntaxException ignored) { } } return out; } - /** Whether a scan-root-relative POSIX path is covered by any {@code --exclude-paths} matcher. */ - public static boolean isExcludedPath(String rel, List matchers) { - if (matchers == null || matchers.isEmpty()) return false; + /** Whether a scan-root-relative POSIX path is covered by any {@code --exclude-paths} pattern. */ + public static boolean isExcludedPath(String rel, List patterns) { + if (patterns == null || patterns.isEmpty()) return false; String c = (rel == null ? "" : rel).replace("\\", "/"); while (c.startsWith("./")) c = c.substring(2); while (c.startsWith("/")) c = c.substring(1); while (c.endsWith("/")) c = c.substring(0, c.length() - 1); if (c.isEmpty()) return false; - Path p = Paths.get(c); - for (PathMatcher m : matchers) { - if (m.matches(p)) return true; + for (Pattern p : patterns) { + if (p.matcher(c).matches()) return true; } return false; } diff --git a/emitters/maven-extension/src/main/java/dev/socket/facts/SocketWorkspacesLifecycleParticipant.java b/emitters/maven-extension/src/main/java/dev/socket/facts/SocketWorkspacesLifecycleParticipant.java new file mode 100644 index 00000000..7ccc1661 --- /dev/null +++ b/emitters/maven-extension/src/main/java/dev/socket/facts/SocketWorkspacesLifecycleParticipant.java @@ -0,0 +1,61 @@ +package dev.socket.facts; + +import org.apache.maven.AbstractMavenLifecycleParticipant; +import org.apache.maven.MavenExecutionException; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.rtinfo.RuntimeInformation; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import java.io.File; +import java.io.IOException; +import java.util.Properties; + +/** + * Sibling of {@link SocketFactsLifecycleParticipant}, gated by {@code -Dsocket.task=socket-workspaces}. + * Hooks {@code afterProjectsRead} (fires once the reactor project list is known, before any + * lifecycle phase runs) since it only needs that list, never a dependency graph. + */ +@Named("socket-workspaces") +@Singleton +public class SocketWorkspacesLifecycleParticipant extends AbstractMavenLifecycleParticipant { + + private final RuntimeInformation runtimeInformation; + + @Inject + public SocketWorkspacesLifecycleParticipant(RuntimeInformation runtimeInformation) { + this.runtimeInformation = runtimeInformation; + } + + @Override + public void afterProjectsRead(MavenSession session) throws MavenExecutionException { + if (!"socket-workspaces".equals(normalize(opt(session, "socket.task")))) { + return; + } + String recordsFile = opt(session, "socket.recordsFile"); + if (recordsFile == null || recordsFile.isEmpty()) { + throw new MavenExecutionException("socket-workspaces requires -Dsocket.recordsFile", new IllegalStateException()); + } + SocketWorkspacesRecordsEngine.Options opts = new SocketWorkspacesRecordsEngine.Options(); + opts.recordsFile = recordsFile; + opts.excludePaths = opt(session, "socket.excludePaths"); + File rootDir = new File(session.getExecutionRootDirectory()); + try { + SocketWorkspacesRecordsEngine.run(session.getProjects(), rootDir, opts, runtimeInformation.getMavenVersion()); + } catch (IOException exception) { + throw new MavenExecutionException("Cannot write socket workspace records", exception); + } + } + + private static String normalize(String task) { + return "socketWorkspaces".equals(task) ? "socket-workspaces" : task; + } + + // -D values arrive as both session user-properties and JVM system properties; prefer the former. + private static String opt(MavenSession session, String key) { + Properties user = session.getUserProperties(); + if (user != null && user.getProperty(key) != null) return user.getProperty(key); + return System.getProperty(key); + } +} diff --git a/emitters/maven-extension/src/main/java/dev/socket/facts/SocketWorkspacesRecordsEngine.java b/emitters/maven-extension/src/main/java/dev/socket/facts/SocketWorkspacesRecordsEngine.java new file mode 100644 index 00000000..c3d50f94 --- /dev/null +++ b/emitters/maven-extension/src/main/java/dev/socket/facts/SocketWorkspacesRecordsEngine.java @@ -0,0 +1,61 @@ +package dev.socket.facts; + +import org.apache.maven.project.MavenProject; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Sibling of {@link SocketFactsRecordsEngine} that emits only `meta`/`project` records from the + * already-populated reactor list, building no dependency graph - cheap workspace discovery for + * `socket manifest setup --dynamic-sbom-inference` without a full facts-generation build. + */ +public final class SocketWorkspacesRecordsEngine { + + public static final class Options { + // Scan-root-relative `--exclude-paths` (CSV): a wholly excluded reactor module is skipped. + public String excludePaths; + public String recordsFile; + } + + private SocketWorkspacesRecordsEngine() {} + + public static void run(List reactor, File rootDir, Options opts, String mavenVersion) + throws IOException { + List excludes = SocketSupport.parseExcludePatterns(opts.excludePaths); + + List lines = new ArrayList<>(); + rec(lines, "meta", "maven", mavenVersion, System.getProperty("java.version")); + + for (MavenProject module : reactor) { + String ws = SocketSupport.workspace(rootDir.toPath(), module.getBasedir().toPath()); + if (SocketSupport.isExcludedPath(ws, excludes)) continue; + rec(lines, "project", ws, module.getGroupId(), module.getArtifactId(), module.getVersion(), ws); + } + + write(opts.recordsFile, lines); + } + + private static void rec(List lines, String... fields) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < fields.length; i++) { + if (i > 0) sb.append('\t'); + sb.append(SocketSupport.escapeField(fields[i])); + } + lines.add(sb.toString()); + } + + private static void write(String recordsFile, List lines) throws IOException { + File out = new File(recordsFile); + if (out.getParentFile() != null) Files.createDirectories(out.getParentFile().toPath()); + try (PrintWriter writer = new PrintWriter(out, StandardCharsets.UTF_8.name())) { + for (String line : lines) writer.print(line + "\n"); + } + } +} diff --git a/emitters/socket-facts.init.gradle b/emitters/socket-facts.init.gradle index 6525741b..d9f95e82 100644 --- a/emitters/socket-facts.init.gradle +++ b/emitters/socket-facts.init.gradle @@ -11,58 +11,28 @@ import java.util.Collections // `Project.findProperty` only exists since Gradle 2.13; fall back to hasProperty/property for older Gradle. gradle.ext.socketProp = { proj, name -> proj.hasProperty(name) ? proj.property(name) : null } -// `-Psocket.excludePaths` → glob PathMatchers, used only to skip whole excluded subprojects -// (source-file-level exclusion is left to the reachability analysis). Each entry variant yields the entry -// itself and `entry/**` so it matches the dir and its subtree (same expansion as the SCA ignore path). A -// trailing `/**` is stripped first, so a user-written `dir/**` still excludes the `dir` directory itself, -// not only its contents. Standard glob semantics (anchored to the scan root, matching the CLI flag): `x` -// is root-level, `**/x` matches at any depth. Mirrors the sbt/maven producers. -// NIO glob requires a slash-adjacent `**` to consume at least one path segment, but the CLI's -// micromatch lets it match zero (`**/x` matches root-level `x`). Emit every variant with `**/` -// occurrences dropped so both semantics hold. -gradle.ext.socketZeroDepthVariants = { String glob -> - def out = new LinkedHashSet() - def work = new ArrayDeque() - work.add(glob) - while (!work.isEmpty()) { - def cur = work.poll() - if (!out.add(cur)) { continue } - int idx = cur.indexOf('**/') - while (idx >= 0) { - if (idx == 0 || cur[idx - 1] == '/') { - def collapsed = cur.substring(0, idx) + cur.substring(idx + 3) - if (!collapsed.isEmpty()) { work.add(collapsed) } - } - idx = cur.indexOf('**/', idx + 1) - } - } - out -} +// `-Psocket.excludePaths` → PRE-COMPILED, comma-joined, anchored regex pattern sources, used only +// to skip whole excluded subprojects (source-file-level exclusion is left to the reachability +// analysis). This package compiles the user-facing globs in src/run/exclude-paths-glob.mts (the single glob +// implementation, tested in CI); this script only Pattern.compile()s what it receives. gradle.ext.socketExcludeMatchersCache = null gradle.ext.socketExcludeMatchers = { if (gradle.ext.socketExcludeMatchersCache != null) return gradle.ext.socketExcludeMatchersCache def raw = gradle.socketProp.call(gradle.rootProject, 'socket.excludePaths')?.toString() - def matchers = [] + def patterns = [] if (raw != null && !raw.trim().isEmpty()) { - def fs = java.nio.file.FileSystems.getDefault() raw.split(',').each { r -> - def g = r.trim().replace('\\', '/') - while (g.startsWith('/')) { g = g.substring(1) } - while (g.endsWith('/')) { g = g.substring(0, g.length() - 1) } - while (g.endsWith('/**')) { - g = g.substring(0, g.length() - 3) - while (g.endsWith('/')) { g = g.substring(0, g.length() - 1) } - } - if (!g.isEmpty()) { - gradle.ext.socketZeroDepthVariants.call(g).each { v -> - matchers << fs.getPathMatcher('glob:' + v) - matchers << fs.getPathMatcher('glob:' + v + '/**') - } + def p = r.trim() + if (!p.isEmpty()) { + // A pattern that doesn't compile is dropped, never thrown: this package emits a + // dialect-portable subset, so this only guards against a broken transport. + try { patterns << java.util.regex.Pattern.compile(p) } + catch (java.util.regex.PatternSyntaxException ignored) { } } } } - gradle.ext.socketExcludeMatchersCache = matchers - matchers + gradle.ext.socketExcludeMatchersCache = patterns + patterns } gradle.ext.socketIsExcluded = { String rel -> def c = (rel == null ? '' : rel).replace('\\', '/') @@ -70,8 +40,7 @@ gradle.ext.socketIsExcluded = { String rel -> while (c.startsWith('/')) { c = c.substring(1) } while (c.endsWith('/')) { c = c.substring(0, c.length() - 1) } if (c.isEmpty()) { return false } - def p = java.nio.file.Paths.get(c) - gradle.ext.socketExcludeMatchers.call().any { m -> m.matches(p) } + gradle.ext.socketExcludeMatchers.call().any { p -> p.matcher(c).matches() } } // Synchronized collections so --parallel-enabled builds don't race; lives on gradle.ext so every diff --git a/emitters/socket-facts.plugin.scala b/emitters/socket-facts.plugin.scala index a9d7881a..dbbb2282 100644 --- a/emitters/socket-facts.plugin.scala +++ b/emitters/socket-facts.plugin.scala @@ -38,7 +38,7 @@ object SocketFactsPlugin extends AutoPlugin { val extracted = Project.extract(st) val allRefs = extracted.structure.allProjectRefs - // `-Dsocket.excludePaths` (scan-root-relative globs): a subproject whose dir is wholly excluded is + // `-Dsocket.excludePaths` (scan-root-relative regex pattern sources): a subproject whose dir is wholly excluded is // never resolved and emits no project record. Source-file-level exclusion is left to the analysis. val excludeMatchers = parseExcludeMatchers() val rootCanonPath = buildRoot.getCanonicalFile.toPath @@ -367,66 +367,35 @@ object SocketFactsPlugin extends AutoPlugin { catch { case _: Throwable => c.toString } } - // `-Dsocket.excludePaths` → glob PathMatchers, used only to skip whole excluded subprojects. Each - // entry variant yields the entry itself and `entry/**` so it matches the dir and its subtree (same expansion - // as the SCA ignore path). A trailing `/**` is stripped first, so a user-written `dir/**` still excludes the - // `dir` directory itself, not only its contents. Standard glob semantics (anchored to the scan root, matching - // the CLI flag): `x` is root-level, `**/x` matches at any depth. Mirrors the gradle/maven producers. - private def parseExcludeMatchers(): Seq[java.nio.file.PathMatcher] = { + // `-Dsocket.excludePaths` → PRE-COMPILED, comma-joined, anchored regex pattern sources, used only + // to skip whole excluded subprojects. This package compiles the user-facing globs in + // src/run/exclude-paths-glob.mts (the single glob implementation, tested in CI); this plugin only + // Pattern.compile()s what it receives. + private def parseExcludeMatchers(): Seq[java.util.regex.Pattern] = { sys.props.get("socket.excludePaths").map(_.trim).filter(_.nonEmpty) match { case None => Nil case Some(raw) => - val fs = java.nio.file.FileSystems.getDefault raw.split(",").toSeq.flatMap { r => - var g = r.trim.replace("\\", "/") - while (g.startsWith("/")) g = g.substring(1) - while (g.endsWith("/")) g = g.substring(0, g.length - 1) - while (g.endsWith("/**")) { - g = g.substring(0, g.length - 3) - while (g.endsWith("/")) g = g.substring(0, g.length - 1) - } - if (g.isEmpty) Nil - else zeroDepthVariants(g).flatMap { v => - Seq(fs.getPathMatcher("glob:" + v), fs.getPathMatcher("glob:" + v + "/**")) - } - } - } - } - - // NIO glob requires a slash-adjacent `**` to consume at least one path segment, but the CLI's - // micromatch lets it match zero (`**/x` matches root-level `x`). Emit every variant with `**/` - // occurrences dropped so both semantics hold. - private def zeroDepthVariants(glob: String): Seq[String] = { - val out = mutable.LinkedHashSet[String]() - val work = mutable.Queue(glob) - while (work.nonEmpty) { - val cur = work.dequeue() - if (out.add(cur)) { - var idx = cur.indexOf("**/") - while (idx >= 0) { - if (idx == 0 || cur.charAt(idx - 1) == '/') { - val collapsed = cur.substring(0, idx) + cur.substring(idx + 3) - if (collapsed.nonEmpty) work.enqueue(collapsed) - } - idx = cur.indexOf("**/", idx + 1) + val p = r.trim + // A pattern that doesn't compile is dropped, never thrown: this package emits a + // dialect-portable subset, so this only guards against a broken transport. + if (p.isEmpty) Nil + else + try Seq(java.util.regex.Pattern.compile(p)) + catch { case _: java.util.regex.PatternSyntaxException => Nil } } - } } - out.toSeq } - private def isExcludedPath(rel: String, matchers: Seq[java.nio.file.PathMatcher]): Boolean = { - if (matchers.isEmpty) false + private def isExcludedPath(rel: String, patterns: Seq[java.util.regex.Pattern]): Boolean = { + if (patterns.isEmpty) false else { var c = (if (rel == null) "" else rel).replace("\\", "/") while (c.startsWith("./")) c = c.substring(2) while (c.startsWith("/")) c = c.substring(1) while (c.endsWith("/")) c = c.substring(0, c.length - 1) if (c.isEmpty) false - else { - val p = java.nio.file.Paths.get(c) - matchers.exists(_.matches(p)) - } + else patterns.exists(_.matcher(c).matches()) } } diff --git a/emitters/socket-workspaces.init.gradle b/emitters/socket-workspaces.init.gradle new file mode 100644 index 00000000..43d440d2 --- /dev/null +++ b/emitters/socket-workspaces.init.gradle @@ -0,0 +1,99 @@ +// Invoke via: +// ./gradlew --init-script socket-workspaces.init.gradle socketWorkspaces + +// Sibling of socket-facts.init.gradle: emits only `meta`/`project` records, no dependency +// resolution. Kept as a separate script so it can't affect that file's already-verified, +// wide Gradle-version compatibility (1.0+). + +// `Project.findProperty` only exists since Gradle 2.13; fall back to hasProperty/property for older Gradle. +gradle.ext.socketProp = { proj, name -> proj.hasProperty(name) ? proj.property(name) : null } + +// `-Psocket.excludePaths` → PRE-COMPILED, comma-joined, anchored regex pattern sources, used only +// to skip whole excluded subprojects. The CLI compiles the user-facing globs in +// exclude-paths-glob.mts (the single glob implementation, tested in CI); this script only +// Pattern.compile()s what it receives. +gradle.ext.socketExcludeMatchersCache = null +gradle.ext.socketExcludeMatchers = { + if (gradle.ext.socketExcludeMatchersCache != null) return gradle.ext.socketExcludeMatchersCache + def raw = gradle.socketProp.call(gradle.rootProject, 'socket.excludePaths')?.toString() + def patterns = [] + if (raw != null && !raw.trim().isEmpty()) { + raw.split(',').each { r -> + def p = r.trim() + if (!p.isEmpty()) { + // A pattern that doesn't compile is dropped, never thrown: the CLI emits a + // dialect-portable subset, so this only guards against a broken transport. + try { patterns << java.util.regex.Pattern.compile(p) } + catch (java.util.regex.PatternSyntaxException ignored) { } + } + } + } + gradle.ext.socketExcludeMatchersCache = patterns + patterns +} +gradle.ext.socketIsExcluded = { String rel -> + def c = (rel == null ? '' : rel).replace('\\', '/') + while (c.startsWith('./')) { c = c.substring(2) } + while (c.startsWith('/')) { c = c.substring(1) } + while (c.endsWith('/')) { c = c.substring(0, c.length() - 1) } + if (c.isEmpty()) { return false } + gradle.ext.socketExcludeMatchers.call().any { p -> p.matcher(c).matches() } +} + +gradle.ext.socketWorkspacesInfo = Collections.synchronizedList([]) + +// Collect every non-excluded project's identity at configuration time - cheap, no dependency +// resolution, no sourceSets/jar introspection (unlike the full facts script, which also stamps +// sources/targets/artifact-ext for resolution-time use). +gradle.projectsEvaluated { g -> + def rootPath = g.rootProject.projectDir.toPath() + def rel = { java.io.File f -> + def r = rootPath.relativize(f.toPath()).toString().replace(File.separator, '/') + r.isEmpty() ? '.' : r + } + g.rootProject.allprojects.each { p -> + if (g.ext.socketIsExcluded.call(rel(p.projectDir))) { + return + } + g.socketWorkspacesInfo.add([ + path : p.path, + group : (p.group ?: '').toString(), + name : p.name, + version: (p.version ?: '').toString(), + dir : rel(p.projectDir), + ]) + } +} + +rootProject { rp -> + def recordsFileOverride = gradle.socketProp.call(rp, 'socket.recordsFile')?.toString() + def defaultRecordsFile = new File(rp.projectDir, '.socket.workspaces.records.tsv').absolutePath + + rp.tasks.create('socketWorkspaces') { + group = 'socket' + description = 'Emits Socket workspace records (project list only; no dependency resolution)' + outputs.upToDateWhen { false } + + doLast { + def esc = { v -> + (v == null ? '' : v.toString()) + .replace('\\', '\\\\').replace('\t', '\\t').replace('\n', '\\n').replace('\r', '\\r') + } + def lines = [] + def rec = { List fields -> lines << fields.collect { esc(it) }.join('\t') } + + rec(['meta', 'gradle', gradle.gradleVersion, System.getProperty('java.version')]) + + def info + synchronized (gradle.socketWorkspacesInfo) { info = new ArrayList(gradle.socketWorkspacesInfo) } + info.each { pi -> + rec(['project', pi.path, pi.group, pi.name, pi.version, pi.dir]) + } + + def outFile = new File(recordsFileOverride ?: defaultRecordsFile) + outFile.parentFile?.mkdirs() + outFile.withWriter('UTF-8') { it.write(lines.join('\n') + '\n') } + println "Socket workspace records written to: ${outFile.absolutePath}" + } + } +} diff --git a/emitters/socket-workspaces.plugin.scala b/emitters/socket-workspaces.plugin.scala new file mode 100644 index 00000000..1087697a --- /dev/null +++ b/emitters/socket-workspaces.plugin.scala @@ -0,0 +1,112 @@ +package socket + +import sbt._ +import sbt.Keys._ + +/** + * Sibling of SocketFactsPlugin (socket-facts.plugin.scala): emits only `meta`/`project` + * records, no dependency resolution. Kept as a separate plugin so it can't affect that + * file's already-verified wide sbt-version compatibility (0.13.x+). + * + * Must compile on Scala 2.10/sbt 0.13 and Scala 2.12/sbt 1.x, same constraint as the facts plugin. + */ +object SocketWorkspacesPlugin extends AutoPlugin { + override def trigger = allRequirements + + object autoImport { + val socketWorkspaces = + taskKey[Unit]("Emit Socket workspace records (project list only; no dependency resolution)") + } + import autoImport._ + + override def projectSettings: Seq[Setting[_]] = Seq( + aggregate in socketWorkspaces := false, + socketWorkspaces := { + val st = state.value + val buildRoot = (baseDirectory in ThisBuild).value + + val extracted = Project.extract(st) + val allRefs = extracted.structure.allProjectRefs + + // `-Dsocket.excludePaths` (scan-root-relative regex pattern sources): a subproject whose dir is wholly excluded + // emits no project record. Mirrors socket-facts.plugin.scala / the gradle / maven producers. + val excludeMatchers = parseExcludeMatchers() + val rootCanonPath = buildRoot.getCanonicalFile.toPath + def relOf(f: File): String = { + val r = rootCanonPath.relativize(f.getCanonicalFile.toPath).toString.replace(java.io.File.separator, "/") + if (r.isEmpty) "." else r + } + def isExcludedRef(ref: ProjectRef): Boolean = + isExcludedPath(relOf(extracted.get(baseDirectory.in(ref))), excludeMatchers) + + def rootIdOf(ref: ProjectRef): ModuleID = { + val sv = extracted.get(scalaVersion.in(ref)) + val sbv = extracted.get(scalaBinaryVersion.in(ref)) + CrossVersion.apply(sv, sbv)(extracted.get(projectID.in(ref))) + } + + val sb = new StringBuilder + def rec(fields: String*): Unit = { + sb.append(fields.map(esc).mkString("\t")); sb.append('\n') + } + + rec("meta", "sbt", extracted.getOpt(sbtVersion).getOrElse(""), sys.props.getOrElse("java.version", "")) + + allRefs.foreach { ref => + if (!isExcludedRef(ref)) { + val mid = rootIdOf(ref) + val ver = if (mid.revision == null) "" else mid.revision + rec("project", ref.project, mid.organization, mid.name, ver, relOf(extracted.get(baseDirectory.in(ref)))) + } + } + + val recordsFile = sys.props.get("socket.recordsFile").filter(_.nonEmpty) match { + case Some(p) => new File(p) + case None => new File(buildRoot, ".socket.workspaces.records.tsv") + } + Option(recordsFile.getParentFile).foreach(_.mkdirs()) + IO.write(recordsFile, sb.toString) + println("Socket workspace records written to: " + recordsFile.getAbsolutePath) + } + ) + + // ---- config selection / path exclusion (mirrors socket-facts.plugin.scala) ---------------- + + // `-Dsocket.excludePaths` → PRE-COMPILED, comma-joined, anchored regex pattern sources, used only + // to skip whole excluded subprojects. The CLI compiles the user-facing globs in + // exclude-paths-glob.mts (the single glob implementation, tested in CI); this plugin only + // Pattern.compile()s what it receives. + private def parseExcludeMatchers(): Seq[java.util.regex.Pattern] = { + sys.props.get("socket.excludePaths").map(_.trim).filter(_.nonEmpty) match { + case None => Nil + case Some(raw) => + raw.split(",").toSeq.flatMap { r => + val p = r.trim + // A pattern that doesn't compile is dropped, never thrown: the CLI emits a + // dialect-portable subset, so this only guards against a broken transport. + if (p.isEmpty) Nil + else + try Seq(java.util.regex.Pattern.compile(p)) + catch { case _: java.util.regex.PatternSyntaxException => Nil } + } + } + } + + private def isExcludedPath(rel: String, patterns: Seq[java.util.regex.Pattern]): Boolean = { + if (patterns.isEmpty) false + else { + var c = (if (rel == null) "" else rel).replace("\\", "/") + while (c.startsWith("./")) c = c.substring(2) + while (c.startsWith("/")) c = c.substring(1) + while (c.endsWith("/")) c = c.substring(0, c.length - 1) + if (c.isEmpty) false + else patterns.exists(_.matcher(c).matches()) + } + } + + // Backslash-escape so a value can never break line/field framing (see records.ts unescape). + private def esc(v: String): String = { + if (v == null) "" + else v.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n").replace("\r", "\\r") + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe38ab7d..74c4a19f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -870,6 +870,19 @@ importers: specifier: 'catalog:' version: 26.1.2 + .claude/hooks/fleet/concurrent-cargo-build-guard: + dependencies: + '@socketsecurity/lib-stable': + specifier: 'catalog:' + version: '@socketsecurity/lib@6.7.0(typescript@7.0.2)' + shell-quote: + specifier: 'catalog:' + version: 1.10.0 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + .claude/hooks/fleet/consumer-grep-nudge: devDependencies: '@types/node': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ce457cec..13cfeaa9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,7 +14,6 @@ catalog: # the soak window, so it carries a minimumReleaseAgeExclude entry below. '@shadscan/cli': 0.8.0 '@sinclair/typebox': 0.34.52 - 'ecc-agentshield': 1.4.0 # typebox 1.x — the unscoped rewrite of @sinclair/typebox. Both names are # pinned while the fleet migrates; the 0.x entry is deleted once no member # imports the scoped name. 1.3.10 is inside the 7-day soak, so it carries a @@ -114,6 +113,7 @@ catalog: # init script (see .config/fleet/playwright/). '@playwright/mcp': 0.0.78 '@vitest/ui': 4.1.10 + 'ecc-agentshield': 1.4.0 # agent-ci runs a repo's GitHub Actions workflows locally in Docker so a # change can be validated before it's pushed (see the `agent-ci` skill). # The published package is a self-contained Node CLI (`dist/cli.js`) — no diff --git a/scripts/repo/check/emitter-assets-are-publishable.mts b/scripts/repo/check/emitter-assets-are-publishable.mts index 0668e195..96a99861 100644 --- a/scripts/repo/check/emitter-assets-are-publishable.mts +++ b/scripts/repo/check/emitter-assets-are-publishable.mts @@ -43,6 +43,10 @@ const logger = getDefaultLogger() export const REQUIRED_EMITTER_FILES: readonly string[] = [ path.join(EMITTERS_DIR, 'socket-facts.init.gradle'), path.join(EMITTERS_DIR, 'socket-facts.plugin.scala'), + // The workspace-enumeration family. A tarball missing these keeps facts + // generation working, so the loss shows up only as an empty subproject list. + path.join(EMITTERS_DIR, 'socket-workspaces.init.gradle'), + path.join(EMITTERS_DIR, 'socket-workspaces.plugin.scala'), path.join(MAVEN_EXTENSION_DIR, 'pom.xml'), path.join(DOTNET_TOOL_DIR, 'socket-facts-dotnet.csproj'), path.join(DOTNET_TOOL_DIR, 'FactsRunner.cs'), diff --git a/scripts/repo/check/sources-are-type-checked.mts b/scripts/repo/check/sources-are-type-checked.mts new file mode 100644 index 00000000..b73964a8 --- /dev/null +++ b/scripts/repo/check/sources-are-type-checked.mts @@ -0,0 +1,97 @@ +/* + * @file `check --all` gate: this package's OWN TypeScript type-checks. + * + * The fleet check config covers scripts/, the repo hooks, and the oxlint + * plugin — not `src/` and not `test/`. `src/` is checked only as a side + * effect of the build's declaration emit, and `test/` is checked by nothing, + * so a type error there survives a green `pnpm run check`. Two real defects + * reached main that way: a type-only import of a symbol its module never + * exported, and a `Record` literal missing the `dotnet` + * key, which read back as `undefined` at runtime. + * + * The config is generated into a tmpdir rather than committed, because + * per-repo config flows through the one member settings file and a new + * standalone tsconfig is blocked. It extends the fleet base so the gate + * checks under the same rules the rest of the tree does. + * + * Usage: node scripts/repo/check/sources-are-type-checked.mts + * Exit 0 when clean, 1 on any diagnostic. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { safeDeleteSync } from '@socketsecurity/lib-stable/fs/safe' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' + +import { isMainModule } from '../../fleet/_shared/is-main-module.mts' +import { REPO_ROOT } from '../paths.mts' + +const logger = getDefaultLogger() + +// `.mts` only. The one `.ts` file under test/ is a fuzz target whose framework +// is an optional devDependency, so including it would fail this gate on a +// checkout that has not installed it — the same false-red the emitter-asset +// skip exists to avoid. It is therefore NOT type-checked, and a stale call +// inside it will not be caught here; that gap is real and known. +export const CHECKED_GLOBS: readonly string[] = [ + 'src/**/*.mts', + 'test/**/*.mts', +] + +export function checkConfig(): string { + return JSON.stringify( + { + extends: path.join(REPO_ROOT, '.config/fleet/tsconfig.base.json'), + compilerOptions: { + // `.mts` specifiers appear in the import paths this package uses. + allowImportingTsExtensions: true, + module: 'nodenext', + moduleResolution: 'nodenext', + noEmit: true, + types: ['node'], + // The config lives in a tmpdir, so typeRoots must point back at the + // repo or `@types/node` resolves against the tmpdir's parents. + typeRoots: [path.join(REPO_ROOT, 'node_modules/@types')], + }, + include: CHECKED_GLOBS.map(glob => path.join(REPO_ROOT, glob)), + }, + undefined, + 2, + ) +} + +export async function main(): Promise { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'socket-facts-tscheck-')) + const configPath = path.join(tmp, 'tsconfig.json') + writeFileSync(configPath, checkConfig()) + try { + await spawn( + process.execPath, + [ + path.join(REPO_ROOT, 'node_modules/typescript/bin/tsc'), + '--noEmit', + '-p', + configPath, + ], + { cwd: REPO_ROOT, stdio: 'inherit' }, + ) + logger.info( + `sources-are-type-checked: ${CHECKED_GLOBS.join(' + ')} type-check clean.`, + ) + } catch { + logger.error( + 'sources-are-type-checked: type errors above. Where: src/ and test/. Saw diagnostics, wanted none. Fix: correct the types; this tree is not covered by the fleet check config.', + ) + process.exitCode = 1 + } finally { + safeDeleteSync(tmp) + } +} + +if (isMainModule(import.meta.url)) { + void main() +} diff --git a/src/assets.mts b/src/assets.mts index c8573457..8bd898a6 100644 --- a/src/assets.mts +++ b/src/assets.mts @@ -13,10 +13,23 @@ export const EMITTERS_DIR: string = path.join(PACKAGE_ROOT, 'emitters') export const GRADLE_INIT_SCRIPT_FILENAME = 'socket-facts.init.gradle' +// Workspace enumeration is a second, cheaper emitter family: it reads the +// already-populated module list and builds no dependency graph. Same jar, same +// init-script mechanism, different task gate. +export const GRADLE_WORKSPACES_INIT_SCRIPT_FILENAME = + 'socket-workspaces.init.gradle' + export const SBT_PLUGIN_FILENAME = 'SocketFactsPlugin.scala' export const SBT_PLUGIN_SOURCE_FILENAME = 'socket-facts.plugin.scala' +// The filename each plugin source is written as inside the run's `plugins/` +// dir; sbt loads by filename, so the two families must not collide. +export const SBT_WORKSPACES_PLUGIN_FILENAME = 'SocketWorkspacesPlugin.scala' + +export const SBT_WORKSPACES_PLUGIN_SOURCE_FILENAME = + 'socket-workspaces.plugin.scala' + export const MAVEN_EXTENSION_JAR_FILENAME = 'socket-facts-maven-extension.jar' export const MAVEN_EXTENSION_DIR: string = path.join( @@ -97,6 +110,10 @@ export function gradleInitScriptPath(): string { return path.join(EMITTERS_DIR, GRADLE_INIT_SCRIPT_FILENAME) } +export function gradleWorkspacesInitScriptPath(): string { + return path.join(EMITTERS_DIR, GRADLE_WORKSPACES_INIT_SCRIPT_FILENAME) +} + export function mavenExtensionJarPath(): string { return path.join(MAVEN_EXTENSION_DIR, MAVEN_EXTENSION_JAR_FILENAME) } @@ -104,3 +121,7 @@ export function mavenExtensionJarPath(): string { export function sbtPluginSourcePath(): string { return path.join(EMITTERS_DIR, SBT_PLUGIN_SOURCE_FILENAME) } + +export function sbtWorkspacesPluginSourcePath(): string { + return path.join(EMITTERS_DIR, SBT_WORKSPACES_PLUGIN_SOURCE_FILENAME) +} diff --git a/src/contract/index.mts b/src/contract/index.mts index 080e1bdc..c461cc51 100644 --- a/src/contract/index.mts +++ b/src/contract/index.mts @@ -10,8 +10,9 @@ export type { } from './sbom.mts' export type { ResolvedArtifactPaths, - ResolvedComponent, ResolvedPathsSidecar, + SidecarComponentEntry, + SidecarProjectEntry, } from './sidecar.mts' export { assertSocketFactsSbom, @@ -20,7 +21,9 @@ export { } from './validate-sbom.mts' export { assertResolvedPathsSidecar, - RESOLVED_COMPONENT_FIELDS, + REACTOR_ENTRY_FIELDS, + SIDECAR_COMPONENT_FIELDS, + SIDECAR_PROJECT_FIELDS, validateResolvedPathsSidecar, } from './validate-sidecar.mts' export type { ContractValidation, ContractViolation } from './violations.mts' diff --git a/src/contract/sidecar.mts b/src/contract/sidecar.mts index e4be04d8..e04bc20c 100644 --- a/src/contract/sidecar.mts +++ b/src/contract/sidecar.mts @@ -1,56 +1,63 @@ -// The `--compute-artifacts-sidecar` wire format: one entry per coordinate the -// build resolved. Per coordinate: `targets`/`sources` present → resolved, and -// the consumer uses the paths; both empty → a pom/BOM (or a NuGet package with -// no runtime assemblies) that resolved with no artifact, which is not a -// failure, because the emitters are fail-closed and record a failure instead -// of an empty entry when an artifact is genuinely missing; the coordinate -// absent → the consumer -// resolves that coordinate itself, with a best-effort probe of local caches, -// then `mvn -Dtransitive=false dependency:get`, then HTTP. +import type { + SocketFactsSbomComponent, + SocketFactsSbomProject, +} from './sbom.mts' + +// The `--compute-artifacts-sidecar` wire format. An entry is the facts SBOM's +// own component/project entry carried through verbatim with resolved paths +// bolted on, so the consumer reads one shape and `id`/`direct`/`dev`/ +// `dependencies`/`subprojectDir`/`resolvedAs` survive the trip untouched. +// +// `targets`/`sources` present, `[]` included → resolution was attempted, and +// an empty array is a successful resolve that found nothing (a pom/BOM, an +// aggregator module, a NuGet package with no runtime assemblies). Both absent +// → there was no computable coordinate to resolve against at all. A coordinate +// missing from a facts file's lists entirely → the consumer resolves it +// itself, with a best-effort probe of local caches, then +// `mvn -Dtransitive=false dependency:get`, then HTTP. // // So the sidecar is an accelerator, not an authority. A coordinate we omit is // not dropped from the scan — it is handed back to exactly the reach-time // resolution this format exists to avoid, at that path's cost and // reliability. Treat a coverage gap as a correctness concern to surface, not // as a silent fallback: see docs/agents.md/repo/contract.md. -// One coordinate can exist in two ecosystems at once — a groupless NuGet id -// and a Maven artifactId collide on the same key — so the ecosystem tag is -// part of the identity, not decoration. The producer ALWAYS writes it; a -// consumer that does not know the key reads a missing tag as 'maven', which -// is what every sidecar written before the tag existed meant. -// -// Emitting this field is gated on the consumer's schema accepting it: see -// "An additive field is a coordinated release" in -// docs/agents.md/repo/contract.md. -export type ResolvedComponent = { - group: string - name: string - version: string - ext: string - // The serialized sidecar carries an explicit JSON null for "no classifier"; - // omitting the key would change the wire shape a `.strict()` consumer parses. - // oxlint-disable-next-line socket/prefer-undefined-over-null -- wire shape - classifier: string | null - // The artifact's purl `type`, carried verbatim as the ecosystem - // discriminator each consumer filters on: 'maven' for gradle/sbt/maven, - // 'nuget' for dotnet. It is exactly the facts component's `type`, so there - // is no narrowing and no re-derivation. - // - // Optional in the TYPE, always written by the PRODUCER. Every sidecar this - // package emits carries the tag; the optionality exists so reading a - // sidecar written before the tag existed is still valid, and such a payload - // means 'maven'. Read it as `entry.ecosystem ?? 'maven'`. - ecosystem?: string | undefined - // Classpath entries (jars / first-party output dirs). For NuGet, runtime +export type SidecarComponentEntry = SocketFactsSbomComponent & { + // Classpath entries: jars, or a sibling first-party project's own build + // output dirs when this dependency edge resolves to one. For NuGet, runtime // (lib/) assemblies and first-party build outputs. - targets: string[] - // First-party source roots; [] for external deps. - sources: string[] + targets?: string[] | undefined + // First-party source roots; `[]` for a genuinely external dependency. + sources?: string[] | undefined } -// Bare array, no envelope. Adding one is a coordinated release, not a local -// edit: see docs/agents.md/repo/contract.md. -export type ResolvedPathsSidecar = ResolvedComponent[] +export type SidecarProjectEntry = SocketFactsSbomProject & { + targets?: string[] | undefined + sources?: string[] | undefined +} + +// Keyed by the absolute path of the `.socket.facts.json` whose own +// projects[]/components[] these entries describe. The key IS the scope: two +// independent reactors that happen to emit the same purl identity can never +// collide, because each is only ever looked up within its own key. That is +// what per-subproject reachability reads. No cross-reactor deduplication — +// the same external dependency resolved by several independent reactors is +// deliberately duplicated across all of their components[], which is simpler +// and safer than a shared bucket. +// +// Changing this shape is a coordinated release with the consumer, never a +// local edit: see docs/agents.md/repo/contract.md. +export type ResolvedPathsSidecar = Record< + string, + { + // This facts file's own first-party modules. + projects: SidecarProjectEntry[] + // This reactor's dependency-position entries: genuinely external + // artifacts, and dependency edges that resolve to a sibling first-party + // project, reported via that project's own source/target roots instead of + // a jar path. + components: SidecarComponentEntry[] + } +> // Resolved on-disk paths for a `withFiles` run, keyed by coordinate. In-memory // only — this never crosses a process boundary, so Map/Set are fine here where diff --git a/src/contract/validate-sidecar.mts b/src/contract/validate-sidecar.mts index 5f342344..fabc8d64 100644 --- a/src/contract/validate-sidecar.mts +++ b/src/contract/validate-sidecar.mts @@ -1,3 +1,4 @@ +import { checkComponent, checkProject } from './validate-sbom.mts' import { checkNoUnknownKeys, checkStringArray, @@ -6,29 +7,45 @@ import { isPlainObject, } from './violations.mts' -import type { ResolvedComponent, ResolvedPathsSidecar } from './sidecar.mts' +import type { ResolvedPathsSidecar } from './sidecar.mts' import type { ContractValidation, ContractViolation } from './violations.mts' -// Mirrors the consumer's strict schema exactly. Sorted so a reader can diff it -// against the consumer's field list at a glance. -export const RESOLVED_COMPONENT_FIELDS: readonly string[] = [ - 'classifier', - 'ecosystem', - 'ext', - 'group', +// Mirrors the consumer's strict schemas exactly, field for field. Sorted so a +// reader can diff each list against the consumer's own `.strict()` object at a +// glance — an entry here that the consumer does not list gets the whole payload +// rejected, not just that field. +export const SIDECAR_COMPONENT_FIELDS: readonly string[] = [ + 'dependencies', + 'dev', + 'direct', + 'id', 'name', + 'namespace', + 'qualifiers', 'sources', 'targets', + 'type', 'version', ] -const REQUIRED_STRING_FIELDS: readonly string[] = [ - 'ext', - 'group', +export const SIDECAR_PROJECT_FIELDS: readonly string[] = [ + 'dependencies', 'name', + 'namespace', + 'qualifiers', + 'resolvedAs', + 'sources', + 'subprojectDir', + 'targets', + 'type', 'version', ] +export const REACTOR_ENTRY_FIELDS: readonly string[] = [ + 'components', + 'projects', +] + export function assertResolvedPathsSidecar( input: unknown, where: string, @@ -44,7 +61,7 @@ export function assertResolvedPathsSidecar( ) } -export function checkComponent( +export function checkReactorEntry( value: unknown, path: string, violations: ContractViolation[], @@ -52,48 +69,75 @@ export function checkComponent( if (!isPlainObject(value)) { violations.push({ path, - message: `saw ${describeType(value)}, wanted an object`, + message: `saw ${describeType(value)}, wanted an object of projects and components`, }) return } - for (let i = 0, { length } = REQUIRED_STRING_FIELDS; i < length; i += 1) { - const field = REQUIRED_STRING_FIELDS[i]! - if (typeof value[field] !== 'string') { + for (const field of ['components', 'projects']) { + if (!Array.isArray(value[field])) { violations.push({ path: `${path}.${field}`, - message: `saw ${describeType(value[field])}, wanted a string`, + message: `saw ${describeType(value[field])}, wanted an array`, }) } } - // An absent key and an explicit null are different bytes on the wire, and the - // consumer's schema accepts only `string | null`. - if (!('classifier' in value)) { - violations.push({ - path: `${path}.classifier`, - message: - 'field is absent, wanted an explicit JSON null when the artifact has no classifier', - }) - } else if ( - value['classifier'] !== null && - typeof value['classifier'] !== 'string' - ) { - violations.push({ - path: `${path}.classifier`, - message: `saw ${describeType(value['classifier'])}, wanted a string or an explicit null`, - }) + const components = value['components'] + if (Array.isArray(components)) { + for (let i = 0, { length } = components; i < length; i += 1) { + checkSidecarComponent( + components[i], + `${path}.components[${i}]`, + violations, + ) + } } - // Strict producer, liberal consumer: this package always writes `ecosystem`, - // and a payload without it is still valid — that is what every sidecar - // written before the tag existed looks like, and it means 'maven'. - if ('ecosystem' in value && typeof value['ecosystem'] !== 'string') { - violations.push({ - path: `${path}.ecosystem`, - message: `saw ${describeType(value['ecosystem'])}, wanted a purl type string such as "maven" or "nuget"`, - }) + const projects = value['projects'] + if (Array.isArray(projects)) { + for (let i = 0, { length } = projects; i < length; i += 1) { + checkSidecarProject(projects[i], `${path}.projects[${i}]`, violations) + } + } + checkNoUnknownKeys(value, REACTOR_ENTRY_FIELDS, path, violations) +} + +// Both fields absent is valid: it means there was no computable coordinate to +// resolve against, and the consumer defaults each to `[]`. +export function checkResolvedPaths( + container: Record, + path: string, + violations: ContractViolation[], +): void { + for (const field of ['sources', 'targets']) { + if (container[field] !== undefined) { + checkStringArray(container[field], `${path}.${field}`, violations) + } } - checkStringArray(value['sources'], `${path}.sources`, violations) - checkStringArray(value['targets'], `${path}.targets`, violations) - checkNoUnknownKeys(value, RESOLVED_COMPONENT_FIELDS, path, violations) +} + +export function checkSidecarComponent( + value: unknown, + path: string, + violations: ContractViolation[], +): void { + checkComponent(value, path, violations) + if (!isPlainObject(value)) { + return + } + checkResolvedPaths(value, path, violations) + checkNoUnknownKeys(value, SIDECAR_COMPONENT_FIELDS, path, violations) +} + +export function checkSidecarProject( + value: unknown, + path: string, + violations: ContractViolation[], +): void { + checkProject(value, path, violations) + if (!isPlainObject(value)) { + return + } + checkResolvedPaths(value, path, violations) + checkNoUnknownKeys(value, SIDECAR_PROJECT_FIELDS, path, violations) } // Narrowing helper rather than a cast: the per-entry checks above already @@ -101,27 +145,29 @@ export function checkComponent( export function isCheckedSidecar( value: unknown, violations: readonly ContractViolation[], -): value is ResolvedComponent[] { - return Array.isArray(value) && violations.length === 0 +): value is ResolvedPathsSidecar { + return isPlainObject(value) && violations.length === 0 } export function validateResolvedPathsSidecar( input: unknown, ): ContractValidation { - const violations: ContractViolation[] = [] - if (!Array.isArray(input)) { + if (!isPlainObject(input)) { return { ok: false, violations: [ { path: '(root)', - message: `saw ${describeType(input)}, wanted a bare array of resolved components`, + message: `saw ${describeType(input)}, wanted an object keyed by absolute .socket.facts.json path`, }, ], } } - for (let i = 0, { length } = input; i < length; i += 1) { - checkComponent(input[i], `[${i}]`, violations) + const violations: ContractViolation[] = [] + const factsFiles = Object.keys(input) + for (let i = 0, { length } = factsFiles; i < length; i += 1) { + const factsFile = factsFiles[i]! + checkReactorEntry(input[factsFile], factsFile, violations) } return isCheckedSidecar(input, violations) ? { ok: true, value: input } diff --git a/src/index.mts b/src/index.mts index f4a9f85e..1e34495e 100644 --- a/src/index.mts +++ b/src/index.mts @@ -8,13 +8,18 @@ export { EMITTERS_DIR, emitterAssetPath, GRADLE_INIT_SCRIPT_FILENAME, + GRADLE_WORKSPACES_INIT_SCRIPT_FILENAME, gradleInitScriptPath, + gradleWorkspacesInitScriptPath, MAVEN_EXTENSION_DIR, MAVEN_EXTENSION_JAR_FILENAME, mavenExtensionJarPath, SBT_PLUGIN_FILENAME, + SBT_WORKSPACES_PLUGIN_FILENAME, SBT_PLUGIN_SOURCE_FILENAME, + SBT_WORKSPACES_PLUGIN_SOURCE_FILENAME, sbtPluginSourcePath, + sbtWorkspacesPluginSourcePath, } from './assets.mts' export { mavenCoordinateKey } from './contract/coordinate.mts' export { SOCKET_FACTS_SBOM_FORMAT } from './contract/sbom.mts' @@ -28,8 +33,9 @@ export type { } from './contract/sbom.mts' export type { ResolvedArtifactPaths, - ResolvedComponent, ResolvedPathsSidecar, + SidecarComponentEntry, + SidecarProjectEntry, } from './contract/sidecar.mts' export { assertSocketFactsSbom, @@ -38,7 +44,9 @@ export { } from './contract/validate-sbom.mts' export { assertResolvedPathsSidecar, - RESOLVED_COMPONENT_FIELDS, + REACTOR_ENTRY_FIELDS, + SIDECAR_COMPONENT_FIELDS, + SIDECAR_PROJECT_FIELDS, validateResolvedPathsSidecar, } from './contract/validate-sidecar.mts' export type { @@ -90,8 +98,14 @@ export type { } from './pipeline/records.mts' export { accumulateSidecar, + attachResolvedPaths, createSidecarAccumulator, + hasResolvedPathsSidecarEntries, + hasSidecarEntries, + mergeResolvedPathsSidecars, + purlSortKey, serializeSidecar, + sortEntriesByPurl, } from './pipeline/sidecar.mts' export type { SidecarAccumulator } from './pipeline/sidecar.mts' export { classifyGradleFailure, GRADLE_DIALECT } from './report/gradle.mts' @@ -137,14 +151,40 @@ export { scrubBuildToolEnv, } from './run/env.mts' export type { BuildEnvPolicy } from './run/env.mts' +export { + compileExcludePathPatterns, + createExcludePathFilter, + excludePathGlobToRegexSource, + excludePathLiteralSource, + serializeExcludePathPatterns, + stripTrailingGlobstar, + translateExcludePathSegment, +} from './run/exclude-paths-glob.mts' +export type { ExcludePathFilter } from './run/exclude-paths-glob.mts' export { assertFactsInvocation } from './run/invocation.mts' export type { FactsInvocation } from './run/invocation.mts' export type { FactsGenerationResult } from './run/result.mts' +export { + emitterProps, + invokeGradle, + invokeMaven, + invokeSbt, + spawnConfigFor, + writeSbtPlugin, +} from './run/invoke-build-tool.mts' export { runFactsGeneration } from './run/run-facts-generation.mts' +export { + enumerateGradleWorkspaces, + enumerateMavenWorkspaces, + enumerateSbtWorkspaces, + enumerateWorkspaces, + workspaceEnumeratorFor, +} from './run/workspace-enumeration.mts' +export type { WorkspaceEnumerationResult } from './run/workspace-enumeration.mts' export { DEFAULT_FACTS_GENERATION_TIMEOUT_MS, FACTS_GENERATION_TIMEOUT_ENV_VAR, factsGenerationTimeoutMs, parseTimeoutMs, } from './run/timeouts.mts' -export type { FactsGenerationOptions } from './run/run-facts-generation.mts' +export type { FactsGenerationOptions } from './run/invocation.mts' diff --git a/src/pipeline/artifact-paths.mts b/src/pipeline/artifact-paths.mts index bcbc7a8c..ce479226 100644 --- a/src/pipeline/artifact-paths.mts +++ b/src/pipeline/artifact-paths.mts @@ -43,8 +43,12 @@ export function buildArtifactPaths( } coords.add(coordKey) const pi = projectsByGav.get(gav(c.group, c.name, c.version ?? '')) - const sources = (pi?.sources ?? []).filter(fileExists) - const targets = [...new Set([...fn.targets, ...(pi?.targets ?? [])])] + const sources = (pi?.sources ?? []).filter(fileExists).toSorted() + // A coordinate that is BOTH a resolved node and a first-party project + // reports the project's own output roots, NOT its published jar: per + // subproject reachability reads the module being analysed, and adding the + // jar would show the analyser a second copy of the same classes. + const targets = [...new Set(pi ? pi.targets : fn.targets)] .filter(fileExists) .toSorted() if (sources.length) { diff --git a/src/pipeline/sidecar.mts b/src/pipeline/sidecar.mts index bff13d60..fc07b5e4 100644 --- a/src/pipeline/sidecar.mts +++ b/src/pipeline/sidecar.mts @@ -1,121 +1,118 @@ import { mavenCoordinateKey } from '../contract/coordinate.mts' -import type { SocketFactsSbom } from '../contract/sbom.mts' +import type { AnyPURL, SocketFactsSbom } from '../contract/sbom.mts' import type { ResolvedArtifactPaths, - ResolvedComponent, ResolvedPathsSidecar, + SidecarComponentEntry, + SidecarProjectEntry, } from '../contract/sidecar.mts' // Emit an entry for every SBOM component AND every first-party project: a // top-level module is a project, not a dependency component, yet its source -// roots are where reachability starts, so the sidecar must carry them. The -// ecosystem is each artifact's own purl `type`, passed through verbatim. +// roots are where reachability starts, so the sidecar must carry them. +// A second call for the same factsFile (a dual-marker directory where two +// build tools both target it) overwrites rather than merges, matching the +// existing last-writer-wins convention for that case. export function accumulateSidecar( acc: SidecarAccumulator, facts: SocketFactsSbom, artifactPaths: ResolvedArtifactPaths, + factsFile: string, ): void { - for (const comp of facts.components) { - addEntry( - acc, - artifactPaths, - comp.namespace ?? '', - comp.name, - comp.version ?? '', - comp.qualifiers?.['ext'] ?? '', - // oxlint-disable-next-line socket/prefer-undefined-over-null -- frozen sidecar contract serializes an explicit JSON null - comp.qualifiers?.['classifier'] ?? null, - comp.type, - ) - } - // First-party modules have no ext/classifier. - for (const proj of facts.projects ?? []) { - addEntry( - acc, - artifactPaths, - proj.namespace ?? '', - proj.name, - proj.version ?? '', - '', - // oxlint-disable-next-line socket/prefer-undefined-over-null -- frozen sidecar contract serializes an explicit JSON null - null, - proj.type, - ) - } + acc.set(factsFile, { + components: facts.components.map(comp => + attachResolvedPaths(comp, artifactPaths), + ), + projects: (facts.projects ?? []).map(proj => + attachResolvedPaths(proj, artifactPaths), + ), + }) } -export function addEntry( - acc: SidecarAccumulator, +// Both fields omitted means resolution could not even be attempted — the only +// case is a degenerate entry with no computable coordinate at all, since every +// entry reaching here already came from a resolved graph node (an unresolved +// dependency lives in the resolution report, not here). +export function attachResolvedPaths( + entry: T, artifactPaths: ResolvedArtifactPaths, - group: string, - name: string, - version: string, - ext: string, - classifier: string | null, - ecosystem: string, -): void { +): T & { targets?: string[] | undefined; sources?: string[] | undefined } { const coordKey = mavenCoordinateKey({ - groupId: group, - artifactId: name, - type: ext || undefined, - classifier: classifier ?? undefined, - version: version || undefined, + groupId: entry.namespace, + artifactId: entry.name, + type: entry.qualifiers?.['ext'], + classifier: entry.qualifiers?.['classifier'], + version: entry.version, }) if (!coordKey) { - return + return { ...entry } } - // Namespaced by ecosystem so a groupless NuGet coordinate can never merge - // with a Maven one. This key is accumulator-internal; the wire format - // carries the ecosystem tag on the entry itself. - const accKey = `${ecosystem}|${coordKey}` - let entry = acc.get(accKey) - if (!entry) { - entry = { - group, - name, - version, - ext, - classifier, - ecosystem, - targets: [], - sources: [], - } - acc.set(accKey, entry) + return { + ...entry, + targets: (artifactPaths.targetsByCoord.get(coordKey) ?? []).toSorted(), + sources: (artifactPaths.sourcesByCoord.get(coordKey) ?? []).toSorted(), } - pushUnique(entry.targets, artifactPaths.targetsByCoord.get(coordKey) ?? []) - pushUnique(entry.sources, artifactPaths.sourcesByCoord.get(coordKey) ?? []) } export function createSidecarAccumulator(): SidecarAccumulator { return new Map() } -// Keyed by full coordinate; unions paths so multiple build roots merge into one. -export type SidecarAccumulator = Map +export function hasResolvedPathsSidecarEntries( + sidecar: ResolvedPathsSidecar, +): boolean { + return Object.keys(sidecar).length > 0 +} -export function pushUnique(into: string[], from: string[]): void { - for (let i = 0, { length } = from; i < length; i += 1) { - const f = from[i]! - if (!into.includes(f)) { - into.push(f) - } - } +export function hasSidecarEntries(acc: SidecarAccumulator): boolean { + return acc.size > 0 } +// Combines two already-serialized sidecars (e.g. the recursive-discovery path +// and the plain auto-manifest path). Keys are already scoped to one facts file +// each and cannot collide between the two inputs in practice, so this is a +// plain merge; the later input wins on a genuine key collision. +export function mergeResolvedPathsSidecars( + a: ResolvedPathsSidecar, + b: ResolvedPathsSidecar, +): ResolvedPathsSidecar { + const merged: ResolvedPathsSidecar = Object.create(null) + return Object.assign(merged, a, b) +} + +export function purlSortKey(entry: AnyPURL): string { + return `${entry.type}:${entry.namespace ?? ''}:${entry.name}:${entry.version ?? ''}:${entry.qualifiers?.['ext'] ?? ''}:${entry.qualifiers?.['classifier'] ?? ''}` +} + +// Keyed by the absolute facts-file path each bucket describes. +export type SidecarAccumulator = Map< + string, + { projects: SidecarProjectEntry[]; components: SidecarComponentEntry[] } +> + export function serializeSidecar( acc: SidecarAccumulator, ): ResolvedPathsSidecar { - const resolved = [...acc.values()] - for (let i = 0, { length } = resolved; i < length; i += 1) { - const entry = resolved[i]! - entry.targets.sort() - entry.sources.sort() + // Null-prototype so a facts-file path like "__proto__" cannot reach + // Object.prototype; typed on the declaration rather than asserted. + const result: ResolvedPathsSidecar = Object.create(null) + const factsFiles = [...acc.keys()].toSorted() + for (let i = 0, { length } = factsFiles; i < length; i += 1) { + const factsFile = factsFiles[i]! + const bucket = acc.get(factsFile)! + result[factsFile] = { + projects: sortEntriesByPurl(bucket.projects), + components: sortEntriesByPurl(bucket.components), + } } - resolved.sort((a, b) => { - const ka = `${a.ecosystem ?? ''}:${a.group}:${a.name}:${a.ext}:${a.classifier ?? ''}:${a.version}` - const kb = `${b.ecosystem ?? ''}:${b.group}:${b.name}:${b.ext}:${b.classifier ?? ''}:${b.version}` + return result +} + +export function sortEntriesByPurl(entries: T[]): T[] { + return entries.toSorted((a, b) => { + const ka = purlSortKey(a) + const kb = purlSortKey(b) return ka < kb ? -1 : ka > kb ? 1 : 0 }) - return resolved } diff --git a/src/run/config-glob.mts b/src/run/config-glob.mts index a82c4bf2..3dedc4c6 100644 --- a/src/run/config-glob.mts +++ b/src/run/config-glob.mts @@ -23,6 +23,8 @@ // before it is sent; it only matters to an out-of-band caller driving a // shipped emitter with hand-written patterns. +import { GLOB_METACHARACTERS, translateGlobClass } from './glob-syntax.mts' + export type ConfigGlobFilter = (name: string) => boolean // Comma-separated globs → anchored regex pattern sources. @@ -64,35 +66,13 @@ export function globToRegexSource(glob: string): string { sb += '.' i += 1 } else if (ch === '[') { - const j = glob.indexOf(']', i + 1) - // Treat as a class only with a non-empty body; else a literal `[`. - if (j <= i + 1) { - sb += '\\[' - i += 1 - } else { - let body = glob.slice(i + 1, j) - const neg = body.startsWith('!') || body.startsWith('^') - if (neg) { - body = body.slice(1) - } - if (!body) { - // `[!]`/`[^]` would emit `[^]`, which JS accepts but Java/.NET - // reject; the JS validity gate below can't catch that, so replicate - // the old per-language fallback: the WHOLE glob matches literally. - return literalRegexSource(glob) - } - // Only literal chars and `-` ranges are meaningful; neutralize - // regex-class tricks (`&` guards Java's `&&` class intersection). - // oxlint-disable-next-line socket/prefer-normalize-path -- regex escaping, not a path separator: a backslash inside a glob's class body is doubled so the emitted pattern reads it as a literal - body = body - .replace(/\\/g, '\\\\') - .replace(/\[/g, '\\[') - .replace(/\]/g, '\\]') - .replace(/&/g, '\\&') - sb += `[${neg ? '^' : ''}${body}]` - i = j + 1 + const cls = translateGlobClass(glob, i) + if (!cls) { + return literalRegexSource(glob) } - } else if ('.\\^$|+(){}]'.includes(ch)) { + sb += cls.source + i = cls.next + } else if (GLOB_METACHARACTERS.includes(ch)) { sb += `\\${ch}` i += 1 } else { diff --git a/src/run/exclude-paths-glob.mts b/src/run/exclude-paths-glob.mts new file mode 100644 index 00000000..3bc4fd2b --- /dev/null +++ b/src/run/exclude-paths-glob.mts @@ -0,0 +1,137 @@ +// Single source of truth for `excludePaths` glob semantics against a build +// root's subproject dirs: anchored, relative, case-sensitive; `*` within one +// path segment, `**` across zero or more segments, `?` within one segment, and +// `[...]` character classes with `[!..]`/`[^..]` negation. Entries are assumed +// pre-validated by the caller (relative, no negation, no `..`, balanced +// brackets, comma-free). A malformed entry falls back to a literal match, +// never throws. +// +// Compiled HERE, once, and handed to every emitter pre-compiled, so there is +// exactly one implementation and one test suite instead of the same algorithm +// re-implemented per language. Sibling of config-glob.mts, which does the same +// for `includeConfigs`/`excludeConfigs`; the difference is that a config glob +// matches a flat name, so its `*` is `.*`, while a path glob is +// segment-aware. +// +// Portability contract: gradle/sbt/maven all run on the JVM and share the same +// regex engine (java.util.regex), so the emitted subset (`.*`, `[^/]*`, +// `[^/]`, `[...]`, `[^...]`, backslash-escaped metacharacters) is +// authoritative for every emitter — this compiler and JS RegExp agree on the +// identical subset. Class bodies escape `&` because Java classes support `&&` +// intersection (JS treats it literally). Each compiled pattern already means +// "this path OR its whole subtree", matching the documented excludePaths +// contract, so an emitter never needs its own "self + /**" expansion. +// Patterns transport comma-joined: an input glob can never contain a comma, +// since the comma-split happens before glob parsing reaches this module. + +import { GLOB_METACHARACTERS, translateGlobClass } from './glob-syntax.mts' + +export type ExcludePathFilter = (relPath: string) => boolean + +export function compileExcludePathPatterns( + paths: readonly string[] | undefined, +): string[] { + return (paths ?? []).map(excludePathGlobToRegexSource) +} + +export function createExcludePathFilter( + paths: readonly string[] | undefined, +): ExcludePathFilter { + const patterns = compileExcludePathPatterns(paths).map(s => new RegExp(s)) + return relPath => patterns.some(p => p.test(relPath)) +} + +export function excludePathGlobToRegexSource(glob: string): string { + const stripped = stripTrailingGlobstar(glob) + if (!stripped) { + // Defensive only: a caller's validation rejects a pattern that reduces to + // "match everything". + return excludePathLiteralSource(glob) + } + try { + const segments = stripped.split('/') + let base = '' + for (let i = 0, { length } = segments; i < length; i += 1) { + const segment = segments[i]! + const isLast = i === length - 1 + if (segment === '**') { + // Zero or more whole segments: mid-pattern, each consumed segment + // carries its own trailing slash; as the last segment, anything + // (including nothing) for the rest of the path. + base += isLast ? '.*' : '(?:[^/]+/)*' + } else { + base += translateExcludePathSegment(segment) + if (!isLast) { + base += '/' + } + } + } + const source = `^(?:${base})(?:/.*)?$` + void new RegExp(source) + return source + } catch { + return excludePathLiteralSource(glob) + } +} + +export function excludePathLiteralSource(glob: string): string { + const escaped = glob.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return `^(?:${escaped})(?:/.*)?$` +} + +// Transport form handed to the emitters: comma-joined pattern sources. Empty +// string when there are no patterns. +export function serializeExcludePathPatterns( + paths: readonly string[] | undefined, +): string { + return compileExcludePathPatterns(paths).join(',') +} + +// Strips a trailing `/**` (and trailing slashes) so `dir` and `dir/**` both +// compile to the same base pattern — the "self OR subtree" wrap is what +// encodes matching the subtree, not the user's own suffix. +export function stripTrailingGlobstar(glob: string): string { + let g = glob + while (g.endsWith('/')) { + g = g.slice(0, -1) + } + while (g.endsWith('/**')) { + g = g.slice(0, -3) + while (g.endsWith('/')) { + g = g.slice(0, -1) + } + } + return g +} + +// Translates one path segment's `*`/`?`/`[...]` — never crosses a `/`. +export function translateExcludePathSegment(segment: string): string { + let sb = '' + let i = 0 + const n = segment.length + while (i < n) { + const ch = segment.charAt(i) + if (ch === '*') { + sb += '[^/]*' + i += 1 + } else if (ch === '?') { + sb += '[^/]' + i += 1 + } else if (ch === '[') { + const cls = translateGlobClass(segment, i) + if (!cls) { + // Signal the caller to fall back to a whole-glob literal match. + throw new Error('empty-class') + } + sb += cls.source + i = cls.next + } else if (GLOB_METACHARACTERS.includes(ch)) { + sb += `\\${ch}` + i += 1 + } else { + sb += ch + i += 1 + } + } + return sb +} diff --git a/src/run/glob-syntax.mts b/src/run/glob-syntax.mts new file mode 100644 index 00000000..760869e6 --- /dev/null +++ b/src/run/glob-syntax.mts @@ -0,0 +1,52 @@ +// The glob syntax both compilers share. config-glob.mts matches a flat config +// name, exclude-paths-glob.mts matches a path and is segment-aware, so they +// differ in what `*` and `?` expand to — but character classes and +// metacharacter escaping are identical, and a divergence there would be a +// silent cross-ecosystem mismatch rather than an error. +// +// Portability contract lives with the callers: the emitted subset behaves the +// same in JS RegExp, Java java.util.regex, and .NET Regex. + +// Escaped rather than emitted raw, so a glob carrying one matches it literally. +export const GLOB_METACHARACTERS = '.\\^$|+(){}]' + +export type GlobClassTranslation = { + // The regex character class to append. + source: string + // Index just past the class in the input. + next: number +} + +// Translates the `[...]` opening at `open`. Returns undefined for an empty +// (possibly negated) body: that would emit `[^]`, which JS accepts and +// Java/.NET reject, so each caller degrades the WHOLE glob to a literal match +// instead of emitting something only one engine parses. +export function translateGlobClass( + glob: string, + open: number, +): GlobClassTranslation | undefined { + const close = glob.indexOf(']', open + 1) + // A non-empty body is required for a class; otherwise it is a literal `[`. + if (close <= open + 1) { + return { source: '\\[', next: open + 1 } + } + let body = glob.slice(open + 1, close) + const negated = body.startsWith('!') || body.startsWith('^') + if (negated) { + body = body.slice(1) + } + if (!body) { + return undefined + } + // Only literal chars and `-` ranges are meaningful; neutralize regex-class + // tricks (`&` guards Java's `&&` class intersection). + // A backslash inside a class body is doubled so the emitted pattern reads + // it as a literal; these are regex escapes, not path separators. + // oxlint-disable-next-line socket/prefer-normalize-path -- regex escaping + body = body + .replace(/\\/g, '\\\\') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]') + .replace(/&/g, '\\&') + return { source: `[${negated ? '^' : ''}${body}]`, next: close + 1 } +} diff --git a/src/run/invocation.mts b/src/run/invocation.mts index 23429622..a542c5ec 100644 --- a/src/run/invocation.mts +++ b/src/run/invocation.mts @@ -2,6 +2,7 @@ import path from 'node:path' import { BUILD_TOOLS, isBuildTool } from './build-tool.mts' +import type { BuildEnvPolicy } from './env.mts' import type { BuildTool } from './build-tool.mts' export function assertFactsInvocation( @@ -100,6 +101,34 @@ export type FactsInvocation = { cwd: string } +export type FactsGenerationOptions = FactsInvocation & { + // Reachability only: also materialize resolved artifact paths. + withFiles?: boolean | undefined + // Path to a newline-delimited GAV file scoping `withFiles` materialization; + // absent means materialize everything. + populateFilesFor?: string | undefined + includeConfigs?: string | undefined + excludeConfigs?: string | undefined + // Scan-root-relative paths whose subprojects are skipped wholesale. Source- + // file-level exclusion belongs to the downstream reachability analysis. + excludePaths?: readonly string[] | undefined + // sbt provisions the project's Scala toolchain (compiler/library/reflect) + // under `/boot`, and withFiles' artifactPaths point into it, so + // that directory must OUTLIVE the call. Supply one and the caller owns + // creating it and deleting it once those paths are consumed. Unset means an + // ephemeral dir, created and removed before the call returns — correct + // without withFiles, and the reason a withFiles sbt run needs this set. + // Only sbt reads it: no other emitter's artifactPaths point at the tmp dir. + tmpDir?: string | undefined + // Default 'scrub'. See env.mts for what 'scrub' removes and why. + envPolicy?: BuildEnvPolicy | undefined + stdio?: 'inherit' | 'pipe' | undefined + signal?: AbortSignal | undefined + // Ceiling on the build tool's wall time; 0 disables it. Defaults to + // SOCKET_FACTS_TIMEOUT_MS, then to DEFAULT_FACTS_GENERATION_TIMEOUT_MS. + timeoutMs?: number | undefined +} + export type Field = 'bin' | 'cwd' | 'env' | 'opts' | 'tool' export function underSpecified( diff --git a/src/run/invoke-build-tool.mts b/src/run/invoke-build-tool.mts new file mode 100644 index 00000000..7bc1977f --- /dev/null +++ b/src/run/invoke-build-tool.mts @@ -0,0 +1,182 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' + +import { assertMavenExtensionBuilt } from '../assets.mts' +import { serializeConfigPatterns } from './config-glob.mts' +import { applyBuildEnvPolicy } from './env.mts' +import { serializeExcludePathPatterns } from './exclude-paths-glob.mts' +import { + assembleFromRecords, + runBuildToolNeverThrow, + withTmpDir, +} from './spawn-build-tool.mts' +import { factsGenerationTimeoutMs } from './timeouts.mts' + +import type { FactsGenerationOptions } from './invocation.mts' +import type { FactsGenerationResult } from './result.mts' +import type { SpawnConfig } from './spawn-build-tool.mts' + +// One invocation per build tool, parameterized by which emitter runs. Two +// emitter families ride these: facts generation, and the cheaper workspace +// enumeration that reads the module list without building a dependency graph. +// Keeping the invocation in one place is what stops the two families' property +// sets and argument order from drifting apart. + +export function emitterProps( + config: FactsGenerationOptions, + prefix: '-D' | '-P', +): string[] { + const cfg = { __proto__: null, ...config } as typeof config + const props: string[] = [] + if (cfg.withFiles) { + props.push(`${prefix}socket.withFiles=true`) + } + if (cfg.populateFilesFor) { + props.push(`${prefix}socket.populateFilesFor=${cfg.populateFilesFor}`) + } + // Globs compile to anchored regex pattern sources HERE, because + // config-glob.mts is the single glob implementation; each emitter only + // compiles the patterns it is handed. + const includePatterns = serializeConfigPatterns(cfg.includeConfigs) + if (includePatterns) { + props.push(`${prefix}socket.includeConfigs=${includePatterns}`) + } + const excludePatterns = serializeConfigPatterns(cfg.excludeConfigs) + if (excludePatterns) { + props.push(`${prefix}socket.excludeConfigs=${excludePatterns}`) + } + // Path globs compile to anchored regex sources HERE too + // (exclude-paths-glob.mts is the single implementation); each emitter just + // Pattern.compile()s what it receives. + const excludePathPatterns = serializeExcludePathPatterns(cfg.excludePaths) + if (excludePathPatterns) { + props.push(`${prefix}socket.excludePaths=${excludePathPatterns}`) + } + return props +} + +export async function invokeGradle( + config: FactsGenerationOptions, + initScriptPath: string, + task: string, + tmpDirPrefix: string, +): Promise { + const cfg = { __proto__: null, ...config } as typeof config + return await withTmpDir(tmpDirPrefix, async tmp => { + const recordsFile = path.join(tmp, 'records.tsv') + // The configuration cache stays off: the init script's resolvedConfiguration + // API and its shared accumulator are not cache-safe. + const args = [ + // Caller options FIRST so ours win: every tool resolves a repeated + // property last-one-wins, measured on maven `-D` and gradle `-P`. Going + // last also covers alias forms a collision check cannot enumerate — + // maven's `--define` is `-D` and matches no `-D` pattern. + ...cfg.opts, + '--init-script', + initScriptPath, + '-Dorg.gradle.configuration-cache=false', + `-Psocket.recordsFile=${recordsFile}`, + ...emitterProps(config, '-P'), + task, + '--no-daemon', + '--console=plain', + ] + const out = await runBuildToolNeverThrow( + cfg.bin, + args, + spawnConfigFor(config), + ) + return await assembleFromRecords(out, recordsFile) + }) +} + +export async function invokeMaven( + config: FactsGenerationOptions, + task: string, + tmpDirPrefix: string, +): Promise { + const cfg = { __proto__: null, ...config } as typeof config + const jarPath = assertMavenExtensionBuilt() + return await withTmpDir(tmpDirPrefix, async tmp => { + const recordsFile = path.join(tmp, 'records.tsv') + // `validate` is the cheapest phase that reaches afterSessionEnd; no compile + // is needed, because the analysis reads configured paths, not classes. + const props = [ + `-Dmaven.ext.class.path=${jarPath}`, + `-Dsocket.task=${task}`, + `-Dsocket.recordsFile=${recordsFile}`, + ...emitterProps(config, '-D'), + ] + const args = [...cfg.opts, ...props, '--batch-mode', 'validate'] + const out = await runBuildToolNeverThrow( + cfg.bin, + args, + spawnConfigFor(config), + ) + return await assembleFromRecords(out, recordsFile) + }) +} + +export async function invokeSbt( + config: FactsGenerationOptions, + pluginSourcePath: string, + pluginDestFilename: string, + task: string, + tmpDirPrefix: string, +): Promise { + const cfg = { __proto__: null, ...config } as typeof config + const run = async (globalBase: string): Promise => { + await writeSbtPlugin(globalBase, pluginSourcePath, pluginDestFilename) + const recordsFile = path.join(globalBase, 'records.tsv') + // A fresh per-run global base rather than ~/.sbt: sbt executes everything + // under plugins/, so a shared path is a code-injection surface. BSP is off + // for this run. + const props = [ + `-Dsbt.global.base=${globalBase}`, + '-Dsbt.server.autostart=false', + `-Dsocket.recordsFile=${recordsFile}`, + ...emitterProps(config, '-D'), + ] + // sbt's launcher does not always honor JAVA_HOME, and never overrides a + // caller-supplied --java-home. + const javaHome = cfg.env['JAVA_HOME'] + const javaHomeOpt = + javaHome && !cfg.opts.includes('--java-home') + ? ['--java-home', javaHome] + : [] + const args = [...javaHomeOpt, ...cfg.opts, ...props, '--batch', task] + const out = await runBuildToolNeverThrow( + cfg.bin, + args, + spawnConfigFor(config), + ) + return await assembleFromRecords(out, recordsFile) + } + // A caller-supplied base is NOT cleaned up here: its lifetime is the reason + // it was supplied. + return cfg.tmpDir + ? await run(cfg.tmpDir) + : await withTmpDir(tmpDirPrefix, run) +} + +export function spawnConfigFor(config: FactsGenerationOptions): SpawnConfig { + const cfg = { __proto__: null, ...config } as typeof config + return { + cwd: cfg.cwd, + env: applyBuildEnvPolicy(cfg.env, cfg.envPolicy ?? 'scrub'), + stdio: cfg.stdio ?? 'pipe', + timeoutMs: cfg.timeoutMs ?? factsGenerationTimeoutMs(), + ...(cfg.signal ? { signal: cfg.signal } : {}), + } +} + +export async function writeSbtPlugin( + globalBase: string, + pluginSourcePath: string, + pluginDestFilename: string, +): Promise { + const source = await fs.readFile(pluginSourcePath, 'utf8') + const pluginsDir = path.join(globalBase, 'plugins') + await fs.mkdir(pluginsDir, { recursive: true }) + await fs.writeFile(path.join(pluginsDir, pluginDestFilename), source) +} diff --git a/src/run/run-facts-generation.mts b/src/run/run-facts-generation.mts index a33f2a63..17ed0e2e 100644 --- a/src/run/run-facts-generation.mts +++ b/src/run/run-facts-generation.mts @@ -1,80 +1,33 @@ -import { promises as fs } from 'node:fs' import path from 'node:path' import { assertDotnetToolBuilt, - assertMavenExtensionBuilt, gradleInitScriptPath, SBT_PLUGIN_FILENAME, sbtPluginSourcePath, } from '../assets.mts' import { serializeConfigPatterns } from './config-glob.mts' -import { applyBuildEnvPolicy } from './env.mts' +import { serializeExcludePathPatterns } from './exclude-paths-glob.mts' import { assertFactsInvocation } from './invocation.mts' -import { factsGenerationTimeoutMs } from './timeouts.mts' +import { + invokeGradle, + invokeMaven, + invokeSbt, + spawnConfigFor, +} from './invoke-build-tool.mts' import { assembleFromRecords, runBuildToolNeverThrow, withTmpDir, } from './spawn-build-tool.mts' -import type { BuildEnvPolicy } from './env.mts' -import type { FactsInvocation } from './invocation.mts' +import type { FactsGenerationOptions } from './invocation.mts' import type { FactsGenerationResult } from './result.mts' -import type { SpawnConfig } from './spawn-build-tool.mts' const FACTS_TASK = 'socketFacts' -export type FactsGenerationOptions = FactsInvocation & { - // Reachability only: also materialize resolved artifact paths. - withFiles?: boolean | undefined - // Path to a newline-delimited GAV file scoping `withFiles` materialization; - // absent means materialize everything. - populateFilesFor?: string | undefined - includeConfigs?: string | undefined - excludeConfigs?: string | undefined - // Scan-root-relative paths whose subprojects are skipped wholesale. Source- - // file-level exclusion belongs to the downstream reachability analysis. - excludePaths?: readonly string[] | undefined - // Default 'scrub'. See env.mts for what 'scrub' removes and why. - envPolicy?: BuildEnvPolicy | undefined - stdio?: 'inherit' | 'pipe' | undefined - signal?: AbortSignal | undefined - // Ceiling on the build tool's wall time; 0 disables it. Defaults to - // SOCKET_FACTS_TIMEOUT_MS, then to DEFAULT_FACTS_GENERATION_TIMEOUT_MS. - timeoutMs?: number | undefined -} - -export function emitterProps( - config: FactsGenerationOptions, - prefix: '-D' | '-P', -): string[] { - const cfg = { __proto__: null, ...config } as typeof config - const props: string[] = [] - if (cfg.withFiles) { - props.push(`${prefix}socket.withFiles=true`) - } - if (cfg.populateFilesFor) { - props.push(`${prefix}socket.populateFilesFor=${cfg.populateFilesFor}`) - } - // Globs compile to anchored regex pattern sources HERE, because - // config-glob.mts is the single glob implementation; each emitter only - // compiles the patterns it is handed. - const includePatterns = serializeConfigPatterns(cfg.includeConfigs) - if (includePatterns) { - props.push(`${prefix}socket.includeConfigs=${includePatterns}`) - } - const excludePatterns = serializeConfigPatterns(cfg.excludeConfigs) - if (excludePatterns) { - props.push(`${prefix}socket.excludeConfigs=${excludePatterns}`) - } - if (cfg.excludePaths?.length) { - // CSV: an entry can never contain a comma, because the CLI flag these come - // from is itself comma-split. - props.push(`${prefix}socket.excludePaths=${cfg.excludePaths.join(',')}`) - } - return props -} +// Maven gates on the hyphenated form; gradle and sbt take the task name. +const MAVEN_FACTS_TASK = 'socket-facts' // The bundled C# tool runs one MSBuild session — evaluate, then an in-process // restore, then read each project.assets.json through NuGet's own APIs — under @@ -90,8 +43,12 @@ export async function runDotnet( const recordsFile = path.join(tmp, 'records.tsv') const includePatterns = serializeConfigPatterns(cfg.includeConfigs) const excludePatterns = serializeConfigPatterns(cfg.excludeConfigs) + const excludePathPatterns = serializeExcludePathPatterns(cfg.excludePaths) const args = [ toolDll, + // Caller options FIRST so ours win: ToolOptions.Parse assigns as it walks + // argv, so a repeated `--records` or `--root` would take the last value. + ...cfg.opts, '--records', recordsFile, '--root', @@ -99,8 +56,8 @@ export async function runDotnet( ...(cfg.withFiles ? ['--with-files'] : []), ...(includePatterns ? ['--include-configs', includePatterns] : []), ...(excludePatterns ? ['--exclude-configs', excludePatterns] : []), + ...(excludePathPatterns ? ['--exclude-paths', excludePathPatterns] : []), ...(cfg.stdio === 'inherit' ? ['--verbose'] : []), - ...cfg.opts, ] const out = await runBuildToolNeverThrow( cfg.bin, @@ -138,103 +95,28 @@ export async function runFactsGeneration( export async function runGradle( config: FactsGenerationOptions, ): Promise { - const cfg = { __proto__: null, ...config } as typeof config - return await withTmpDir('socket-gradle-facts-', async tmp => { - const recordsFile = path.join(tmp, 'records.tsv') - // The configuration cache stays off: the init script's resolvedConfiguration - // API and its shared accumulator are not cache-safe. - const args = [ - '--init-script', - gradleInitScriptPath(), - '-Dorg.gradle.configuration-cache=false', - `-Psocket.recordsFile=${recordsFile}`, - ...emitterProps(config, '-P'), - ...cfg.opts, - FACTS_TASK, - '--no-daemon', - '--console=plain', - ] - const out = await runBuildToolNeverThrow( - cfg.bin, - args, - spawnConfigFor(config), - ) - return await assembleFromRecords(out, recordsFile) - }) + return await invokeGradle( + config, + gradleInitScriptPath(), + FACTS_TASK, + 'socket-gradle-facts-', + ) } export async function runMaven( config: FactsGenerationOptions, ): Promise { - const cfg = { __proto__: null, ...config } as typeof config - const jarPath = assertMavenExtensionBuilt() - return await withTmpDir('socket-maven-facts-', async tmp => { - const recordsFile = path.join(tmp, 'records.tsv') - // `validate` is the cheapest phase that reaches afterSessionEnd; no compile - // is needed, because the analysis reads configured paths, not classes. - const props = [ - `-Dmaven.ext.class.path=${jarPath}`, - '-Dsocket.task=socket-facts', - `-Dsocket.recordsFile=${recordsFile}`, - ...emitterProps(config, '-D'), - ] - const args = [...props, ...cfg.opts, '--batch-mode', 'validate'] - const out = await runBuildToolNeverThrow( - cfg.bin, - args, - spawnConfigFor(config), - ) - return await assembleFromRecords(out, recordsFile) - }) + return await invokeMaven(config, MAVEN_FACTS_TASK, 'socket-maven-facts-') } export async function runSbt( config: FactsGenerationOptions, ): Promise { - const cfg = { __proto__: null, ...config } as typeof config - return await withTmpDir('socket-sbt-facts-', async globalBase => { - await writeSbtPlugin(globalBase) - const recordsFile = path.join(globalBase, 'records.tsv') - // A fresh per-run global base rather than ~/.sbt: sbt executes everything - // under plugins/, so a shared path is a code-injection surface. BSP is off - // for this run. - const props = [ - `-Dsbt.global.base=${globalBase}`, - '-Dsbt.server.autostart=false', - `-Dsocket.recordsFile=${recordsFile}`, - ...emitterProps(config, '-D'), - ] - // sbt's launcher does not always honor JAVA_HOME, and never overrides a - // caller-supplied --java-home. - const javaHome = cfg.env['JAVA_HOME'] - const javaHomeOpt = - javaHome && !cfg.opts.includes('--java-home') - ? ['--java-home', javaHome] - : [] - const args = [...javaHomeOpt, ...props, ...cfg.opts, '--batch', FACTS_TASK] - const out = await runBuildToolNeverThrow( - cfg.bin, - args, - spawnConfigFor(config), - ) - return await assembleFromRecords(out, recordsFile) - }) -} - -export function spawnConfigFor(config: FactsGenerationOptions): SpawnConfig { - const cfg = { __proto__: null, ...config } as typeof config - return { - cwd: cfg.cwd, - env: applyBuildEnvPolicy(cfg.env, cfg.envPolicy ?? 'scrub'), - stdio: cfg.stdio ?? 'pipe', - timeoutMs: cfg.timeoutMs ?? factsGenerationTimeoutMs(), - ...(cfg.signal ? { signal: cfg.signal } : {}), - } -} - -export async function writeSbtPlugin(globalBase: string): Promise { - const source = await fs.readFile(sbtPluginSourcePath(), 'utf8') - const pluginsDir = path.join(globalBase, 'plugins') - await fs.mkdir(pluginsDir, { recursive: true }) - await fs.writeFile(path.join(pluginsDir, SBT_PLUGIN_FILENAME), source) + return await invokeSbt( + config, + sbtPluginSourcePath(), + SBT_PLUGIN_FILENAME, + FACTS_TASK, + 'socket-sbt-facts-', + ) } diff --git a/src/run/workspace-enumeration.mts b/src/run/workspace-enumeration.mts new file mode 100644 index 00000000..354df6e8 --- /dev/null +++ b/src/run/workspace-enumeration.mts @@ -0,0 +1,95 @@ +import { + gradleWorkspacesInitScriptPath, + SBT_WORKSPACES_PLUGIN_FILENAME, + sbtWorkspacesPluginSourcePath, +} from '../assets.mts' +import { assertFactsInvocation } from './invocation.mts' +import { invokeGradle, invokeMaven, invokeSbt } from './invoke-build-tool.mts' + +import type { SocketFactsSbomProject } from '../contract/sbom.mts' +import type { FactsGenerationOptions } from './invocation.mts' +import type { FactsGenerationResult } from './result.mts' + +const WORKSPACES_TASK = 'socketWorkspaces' + +// Maven gates on the hyphenated form; gradle and sbt take the task name. +const MAVEN_WORKSPACES_TASK = 'socket-workspaces' + +// Cheap subproject discovery: the emitters read the already-populated module +// list and build no dependency graph, so this answers "which subprojects exist" +// without paying for resolution. A full facts run gets the same list for free +// as a side effect, so reach for this only when the list is all you need. +export type WorkspaceEnumerationResult = { + code: number + projects: SocketFactsSbomProject[] + stderr: string + stdout: string +} + +export async function enumerateGradleWorkspaces( + config: FactsGenerationOptions, +): Promise { + return await invokeGradle( + config, + gradleWorkspacesInitScriptPath(), + WORKSPACES_TASK, + 'socket-gradle-workspaces-', + ) +} + +export async function enumerateMavenWorkspaces( + config: FactsGenerationOptions, +): Promise { + return await invokeMaven( + config, + MAVEN_WORKSPACES_TASK, + 'socket-maven-workspaces-', + ) +} + +export async function enumerateSbtWorkspaces( + config: FactsGenerationOptions, +): Promise { + return await invokeSbt( + config, + sbtWorkspacesPluginSourcePath(), + SBT_WORKSPACES_PLUGIN_FILENAME, + WORKSPACES_TASK, + 'socket-sbt-workspaces-', + ) +} + +export async function enumerateWorkspaces( + config: FactsGenerationOptions, +): Promise { + const cfg = { __proto__: null, ...config } as typeof config + assertFactsInvocation(config) + const result = await workspaceEnumeratorFor(cfg.tool, config) + return { + code: result.code, + projects: result.facts.projects ?? [], + stderr: result.stderr, + stdout: result.stdout, + } +} + +// dotnet is absent on purpose: the dotnet tool enumerates its projects as part +// of the one MSBuild session it already runs, so there is no cheaper pass to +// offer and no second emitter to gate. +export async function workspaceEnumeratorFor( + tool: FactsGenerationOptions['tool'], + config: FactsGenerationOptions, +): Promise { + switch (tool) { + case 'gradle': + return await enumerateGradleWorkspaces(config) + case 'maven': + return await enumerateMavenWorkspaces(config) + case 'sbt': + return await enumerateSbtWorkspaces(config) + default: + throw new Error( + `Unsupported build tool for workspace enumeration. Where: enumerateWorkspaces. Saw ${tool}, wanted gradle, maven, or sbt. Fix: run full facts generation instead, which enumerates projects as part of its own pass.`, + ) + } +} diff --git a/test/repo/compat/dotnet-dynamic-version.test.mts b/test/repo/compat/dotnet-dynamic-version.test.mts new file mode 100644 index 00000000..4ede8000 --- /dev/null +++ b/test/repo/compat/dotnet-dynamic-version.test.mts @@ -0,0 +1,157 @@ +// socket-lint: mirror-exempt — a conformance suite over the dotnet EMITTER, not +// over a TypeScript module. +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { spawn } from '@socketsecurity/lib/process/spawn/child' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { runFactsGeneration } from '../../../src/run/run-facts-generation.mts' +import { + compatEnv, + enforceOrAnnounceSkip, + findBuildToolBin, + skipReasonFor, +} from './lib/toolchain.mts' + +import type { FactsGenerationResult } from '../../../src/run/result.mts' + +// The gradle and maven fixtures hold the JVM resolvers to resolve-once: resolve +// at scan time against the developer's real build, never from a manifest parse +// or a cache. The dotnet resolver had no equivalent, so nothing held it to that +// bar — this closes it. +// +// The oracle is MSBuild's own property evaluation, never a golden file. Scope +// boundary worth naming: a full TRANSITIVE NuGet oracle would need a local +// package feed, so this fixture proves the resolve-once property and the +// project-graph edge, not transitive package resolution. +const FIXTURE_SOURCE_DIR = path.join( + import.meta.dirname, + 'dotnet-dynamic-version', +) + +const VERSION_FILENAME = 'version.txt' + +let workspace: { projectDir: string; computedVersion: string } | undefined +let cleanup: (() => Promise) | undefined +let result: FactsGenerationResult | undefined + +describe('the dotnet dynamic-version fixture cannot be satisfied statically', () => { + // Runs with no .NET toolchain: it asserts a property of the fixture text + // itself, which is what makes the build assertions below meaningful. + it('names no resolved version anywhere in the committed project files', async () => { + const entries = await fs.readdir(FIXTURE_SOURCE_DIR, { + recursive: true, + withFileTypes: true, + }) + let checked = 0 + for (const entry of entries) { + if (!entry.isFile()) { + continue + } + const text = await fs.readFile( + path.join(entry.parentPath, entry.name), + 'utf8', + ) + // The only version-shaped thing allowed is the file read itself. + expect(text).not.toMatch(/\s*\d/) + checked += 1 + } + expect(checked).toBeGreaterThan(0) + }) + + it('reads its version from a file the checkout does not carry', async () => { + const csproj = await fs.readFile( + path.join(FIXTURE_SOURCE_DIR, 'app/App.csproj'), + 'utf8', + ) + expect(csproj).toContain('System.IO.File]::ReadAllText') + expect(csproj).toContain(VERSION_FILENAME) + await expect( + fs.access(path.join(FIXTURE_SOURCE_DIR, VERSION_FILENAME)), + ).rejects.toThrow() + }) +}) + +describe('the dotnet emitter resolves once, against the real build', () => { + beforeAll(async () => { + const reason = skipReasonFor('dotnet') + if (reason) { + enforceOrAnnounceSkip(reason) + return + } + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'socket-facts-dotnet-dynver-'), + ) + const projectDir = path.join(root, 'project') + await fs.cp(FIXTURE_SOURCE_DIR, projectDir, { recursive: true }) + // Generated per run, so it appears in no committed file. No trailing + // newline: MSBuild's ReadAllText does not trim. + const computedVersion = `0.0.0-resolved-${process.hrtime.bigint()}` + await fs.writeFile(path.join(projectDir, VERSION_FILENAME), computedVersion) + workspace = { computedVersion, projectDir } + cleanup = async () => { + await fs.rm(root, { force: true, recursive: true }) + } + result = await runFactsGeneration({ + bin: findBuildToolBin('dotnet')!, + cwd: projectDir, + env: compatEnv(), + opts: [], + tool: 'dotnet', + withFiles: true, + }) + }, 300_000) + + afterAll(async () => { + await cleanup?.() + }) + + it('completes with no resolution failures', () => { + if (!workspace) { + return + } + expect(result?.code).toBe(0) + expect(result?.report.failures).toEqual([]) + }) + + it('carries the computed version on every project', () => { + if (!workspace) { + return + } + const projects = result?.facts.projects ?? [] + expect(projects.map(p => p.name).toSorted()).toStrictEqual(['App', 'Lib']) + for (const project of projects) { + expect(project.version).toBe(workspace.computedVersion) + } + }) + + // The csproj states the ProjectReference as a PATH and gives no version, so + // the version on this edge exists only after MSBuild evaluates the referenced + // project. A static resolver cannot produce it at all. + it('carries the computed version on the project-reference edge', () => { + if (!workspace) { + return + } + const lib = result?.facts.components.find(c => c.name === 'Lib') + expect(lib, 'no component for the referenced project').toBeDefined() + expect(lib?.type).toBe('nuget') + expect(lib?.version).toBe(workspace.computedVersion) + }) + + it("agrees with MSBuild's own evaluation of the version", async () => { + if (!workspace) { + return + } + // The oracle: dotnet reporting the property itself, not a golden file. + const out = await spawn( + findBuildToolBin('dotnet')!, + ['msbuild', 'app/App.csproj', '-getProperty:Version', '-nologo'], + { cwd: workspace.projectDir, env: compatEnv(), stdio: 'pipe' }, + ) + const reported = String(out.stdout).trim() + expect(reported).toBe(workspace.computedVersion) + expect(result?.facts.projects?.[0]?.version).toBe(reported) + }) +}) diff --git a/test/repo/compat/dotnet-dynamic-version/App.slnx b/test/repo/compat/dotnet-dynamic-version/App.slnx new file mode 100644 index 00000000..3c4a68cc --- /dev/null +++ b/test/repo/compat/dotnet-dynamic-version/App.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/test/repo/compat/dotnet-dynamic-version/app/App.csproj b/test/repo/compat/dotnet-dynamic-version/app/App.csproj new file mode 100644 index 00000000..4b4bf1eb --- /dev/null +++ b/test/repo/compat/dotnet-dynamic-version/app/App.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)../version.txt')) + + + + + diff --git a/test/repo/compat/dotnet-dynamic-version/lib/Lib.csproj b/test/repo/compat/dotnet-dynamic-version/lib/Lib.csproj new file mode 100644 index 00000000..69ee30d3 --- /dev/null +++ b/test/repo/compat/dotnet-dynamic-version/lib/Lib.csproj @@ -0,0 +1,7 @@ + + + + net8.0 + $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)../version.txt')) + + diff --git a/test/repo/compat/exclude-paths-dotnet.test.mts b/test/repo/compat/exclude-paths-dotnet.test.mts new file mode 100644 index 00000000..bf734f03 --- /dev/null +++ b/test/repo/compat/exclude-paths-dotnet.test.mts @@ -0,0 +1,105 @@ +// socket-lint: mirror-exempt — an end-to-end suite over the dotnet EMITTER's +// exclude handling, not over a TypeScript module. +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { runFactsGeneration } from '../../../src/run/run-facts-generation.mts' +import { + compatEnv, + enforceOrAnnounceSkip, + findBuildToolBin, + skipReasonFor, +} from './lib/toolchain.mts' + +import type { FactsGenerationResult } from '../../../src/run/result.mts' + +// excludePaths crosses a language boundary as pre-compiled regex sources: this +// package compiles the globs, the emitter only compiles them to a Regex. A +// mismatch there matches NOTHING silently — a wrong scan, not a crash — so the +// unit tests on either side of the boundary cannot catch it. This suite drives +// a real dotnet build across that boundary. +const FIXTURE_SOURCE_DIR = path.join( + import.meta.dirname, + 'exclude-paths-dotnet', +) + +let workspace: { projectDir: string } | undefined +let cleanup: (() => Promise) | undefined + +async function generate( + excludePaths?: string[] | undefined, +): Promise { + return await runFactsGeneration({ + bin: findBuildToolBin('dotnet')!, + cwd: workspace!.projectDir, + env: compatEnv(), + opts: [], + tool: 'dotnet', + ...(excludePaths ? { excludePaths } : {}), + }) +} + +function projectNames(result: FactsGenerationResult): string[] { + return (result.facts.projects ?? []).map(p => p.name).toSorted() +} + +// A .slnx contributes no project of its own, so unlike the Gradle fixture +// there is no root entry in these expectations. +describe('excludePaths reaches the dotnet emitter', () => { + beforeAll(async () => { + const reason = skipReasonFor('dotnet') + if (reason) { + enforceOrAnnounceSkip(reason) + return + } + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'socket-facts-excl-net-'), + ) + const projectDir = path.join(root, 'project') + await fs.cp(FIXTURE_SOURCE_DIR, projectDir, { recursive: true }) + workspace = { projectDir } + cleanup = async () => { + await fs.rm(root, { force: true, recursive: true }) + } + }) + + afterAll(async () => { + await cleanup?.() + }) + + it('records every project when nothing is excluded', async () => { + if (!workspace) { + return + } + expect(projectNames(await generate())).toStrictEqual(['App', 'Legacy']) + }) + + it('drops a wholly excluded project', async () => { + if (!workspace) { + return + } + expect(projectNames(await generate(['legacy']))).toStrictEqual(['App']) + }) + + // The compiled pattern already means "this dir OR its subtree", so a + // user-written trailing `/**` must not change the outcome. + it('treats a trailing globstar as the same exclusion', async () => { + if (!workspace) { + return + } + expect(projectNames(await generate(['legacy/**']))).toStrictEqual(['App']) + }) + + it('leaves a non-matching exclude path alone', async () => { + if (!workspace) { + return + } + expect(projectNames(await generate(['legacyx']))).toStrictEqual([ + 'App', + 'Legacy', + ]) + }) +}) diff --git a/test/repo/compat/exclude-paths-dotnet/App.slnx b/test/repo/compat/exclude-paths-dotnet/App.slnx new file mode 100644 index 00000000..7faf03fb --- /dev/null +++ b/test/repo/compat/exclude-paths-dotnet/App.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/test/repo/compat/exclude-paths-dotnet/app/App.csproj b/test/repo/compat/exclude-paths-dotnet/app/App.csproj new file mode 100644 index 00000000..ad107888 --- /dev/null +++ b/test/repo/compat/exclude-paths-dotnet/app/App.csproj @@ -0,0 +1,7 @@ + + + + net8.0 + + diff --git a/test/repo/compat/exclude-paths-dotnet/legacy/Legacy.csproj b/test/repo/compat/exclude-paths-dotnet/legacy/Legacy.csproj new file mode 100644 index 00000000..ad107888 --- /dev/null +++ b/test/repo/compat/exclude-paths-dotnet/legacy/Legacy.csproj @@ -0,0 +1,7 @@ + + + + net8.0 + + diff --git a/test/repo/compat/exclude-paths-gradle/app/build.gradle b/test/repo/compat/exclude-paths-gradle/app/build.gradle new file mode 100644 index 00000000..66961f4c --- /dev/null +++ b/test/repo/compat/exclude-paths-gradle/app/build.gradle @@ -0,0 +1 @@ +// Intentionally empty: the root build applies the java plugin. diff --git a/test/repo/compat/exclude-paths-gradle/build.gradle b/test/repo/compat/exclude-paths-gradle/build.gradle new file mode 100644 index 00000000..a45ec85b --- /dev/null +++ b/test/repo/compat/exclude-paths-gradle/build.gradle @@ -0,0 +1,8 @@ +// Two subprojects, no external dependencies: the point of the fixture is which +// PROJECTS reach the facts, not what they resolve. `legacy` is the one the +// exclude-path case removes. +allprojects { + apply plugin: 'java' + group = 'com.example' + version = '1.0.0' +} diff --git a/test/repo/compat/exclude-paths-gradle/legacy/build.gradle b/test/repo/compat/exclude-paths-gradle/legacy/build.gradle new file mode 100644 index 00000000..66961f4c --- /dev/null +++ b/test/repo/compat/exclude-paths-gradle/legacy/build.gradle @@ -0,0 +1 @@ +// Intentionally empty: the root build applies the java plugin. diff --git a/test/repo/compat/exclude-paths-gradle/settings.gradle b/test/repo/compat/exclude-paths-gradle/settings.gradle new file mode 100644 index 00000000..f918a8df --- /dev/null +++ b/test/repo/compat/exclude-paths-gradle/settings.gradle @@ -0,0 +1,3 @@ +rootProject.name = 'exclude-paths-fixture' +include 'app' +include 'legacy' diff --git a/test/repo/compat/exclude-paths.test.mts b/test/repo/compat/exclude-paths.test.mts new file mode 100644 index 00000000..7d9ead2f --- /dev/null +++ b/test/repo/compat/exclude-paths.test.mts @@ -0,0 +1,116 @@ +// socket-lint: mirror-exempt — an end-to-end suite over the Gradle EMITTER's +// exclude handling, not over a TypeScript module. +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { runFactsGeneration } from '../../../src/run/run-facts-generation.mts' +import { + compatEnv, + enforceOrAnnounceSkip, + findBuildToolBin, + skipReasonFor, +} from './lib/toolchain.mts' + +import type { FactsGenerationResult } from '../../../src/run/result.mts' + +// excludePaths crosses a language boundary as pre-compiled regex sources: this +// package compiles the globs, the emitter only Pattern.compile()s them. A +// mismatch there matches NOTHING silently — a wrong scan, not a crash — so the +// unit tests on either side of the boundary cannot catch it. This suite drives +// a real Gradle build across that boundary. +const FIXTURE_SOURCE_DIR = path.join( + import.meta.dirname, + 'exclude-paths-gradle', +) + +let workspace: { projectDir: string; gradleUserHome: string } | undefined +let cleanup: (() => Promise) | undefined + +async function generate( + excludePaths?: string[] | undefined, +): Promise { + return await runFactsGeneration({ + bin: findBuildToolBin('gradle')!, + cwd: workspace!.projectDir, + env: compatEnv(), + opts: ['--offline', '-g', workspace!.gradleUserHome], + tool: 'gradle', + ...(excludePaths ? { excludePaths } : {}), + }) +} + +function projectNames(result: FactsGenerationResult): string[] { + return (result.facts.projects ?? []).map(p => p.name).toSorted() +} + +// The root build is itself a project, so it is present in every expectation +// below; only the subprojects are what an exclude path removes here. +const ROOT = 'exclude-paths-fixture' + +describe('excludePaths reaches the gradle emitter', () => { + beforeAll(async () => { + const reason = skipReasonFor('gradle') + if (reason) { + enforceOrAnnounceSkip(reason) + return + } + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-facts-excl-')) + const projectDir = path.join(root, 'project') + await fs.cp(FIXTURE_SOURCE_DIR, projectDir, { recursive: true }) + workspace = { gradleUserHome: path.join(root, 'gradle-home'), projectDir } + cleanup = async () => { + await fs.rm(root, { force: true, recursive: true }) + } + }) + + afterAll(async () => { + await cleanup?.() + }) + + it('records every subproject when nothing is excluded', async () => { + if (!workspace) { + return + } + expect(projectNames(await generate())).toStrictEqual([ + 'app', + ROOT, + 'legacy', + ]) + }) + + it('drops a wholly excluded subproject', async () => { + if (!workspace) { + return + } + expect(projectNames(await generate(['legacy']))).toStrictEqual([ + 'app', + ROOT, + ]) + }) + + // The compiled pattern already means "this dir OR its subtree", so a + // user-written trailing `/**` must not change the outcome. + it('treats a trailing globstar as the same exclusion', async () => { + if (!workspace) { + return + } + expect(projectNames(await generate(['legacy/**']))).toStrictEqual([ + 'app', + ROOT, + ]) + }) + + it('leaves a non-matching exclude path alone', async () => { + if (!workspace) { + return + } + expect(projectNames(await generate(['legacyx']))).toStrictEqual([ + 'app', + ROOT, + 'legacy', + ]) + }) +}) diff --git a/test/repo/compat/lib/toolchain.mts b/test/repo/compat/lib/toolchain.mts index bb25ffad..1bdb1401 100644 --- a/test/repo/compat/lib/toolchain.mts +++ b/test/repo/compat/lib/toolchain.mts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import { existsSync } from 'node:fs' import path from 'node:path' import process from 'node:process' @@ -13,12 +14,14 @@ import type { BuildTool } from '../../../../src/run/build-tool.mts' export const REQUIRE_COMPAT_ENV_VAR = 'SOCKET_FACTS_REQUIRE_COMPAT' const BIN_ENV_VAR: Readonly> = Object.freeze({ + dotnet: 'SOCKET_FACTS_DOTNET_BIN', gradle: 'SOCKET_FACTS_GRADLE_BIN', maven: 'SOCKET_FACTS_MAVEN_BIN', sbt: 'SOCKET_FACTS_SBT_BIN', }) const BIN_NAME: Readonly> = Object.freeze({ + dotnet: 'dotnet', gradle: 'gradle', maven: 'mvn', sbt: 'sbt', @@ -26,14 +29,27 @@ const BIN_NAME: Readonly> = Object.freeze({ // How an absent emitter asset gets repaired. The gradle init script and the sbt // plugin source are committed, so an absent one is a broken checkout. The Maven -// extension is a jar that a JDK has to build, which `pnpm run build` -// deliberately does not do — a plain checkout carries no JDK obligation. +// extension and the dotnet tool are build outputs that a JDK / the .NET SDK has +// to produce, which `pnpm run build` deliberately does not do — a plain +// checkout carries no JDK or SDK obligation. const ASSET_FIX: Readonly> = Object.freeze({ + dotnet: 'run `pnpm run build:dotnet-tool` (needs the .NET SDK)', gradle: 'restore the committed emitter source', maven: 'run `pnpm run build:maven-extension` (needs a JDK)', sbt: 'restore the committed emitter source', }) +// The build outputs a capable machine can produce on demand. gradle and sbt +// are absent on purpose: their emitters are committed sources, so a missing +// one is a broken checkout that no build step repairs. +const ASSET_BUILDER: Readonly>> = + Object.freeze({ + dotnet: 'scripts/repo/build-dotnet-tool.mts', + maven: 'scripts/repo/build-maven-extension.mts', + }) + +const REPO_ROOT = path.resolve(import.meta.dirname, '../../../..') + export function compatIsRequired(): boolean { return Boolean(process.env[REQUIRE_COMPAT_ENV_VAR]) } @@ -94,7 +110,7 @@ export function skipReasonFor(tool: BuildTool): string | undefined { `Set ${REQUIRE_COMPAT_ENV_VAR}=1 to turn this skip into a failure.` ) } - return emitterAssetSkipReason(tool, emitterAssetPath(tool)) + return ensureEmitterAsset(tool) } // The second precondition, split out so it is testable without a checkout that @@ -115,6 +131,32 @@ export function emitterAssetSkipReason( ) } +// A skip should mean "this machine cannot run the suite", never "you forgot a +// build step". Where the emitter asset is a build output and the toolchain is +// present, build it rather than skipping 7 conformance tests silently on a +// machine that could have run them. +export function ensureEmitterAsset(tool: BuildTool): string | undefined { + const assetPath = emitterAssetPath(tool) + if (existsSync(assetPath)) { + return undefined + } + const builder = ASSET_BUILDER[tool] + if (!builder) { + return emitterAssetSkipReason(tool, assetPath) + } + try { + execFileSync(process.execPath, [path.join(REPO_ROOT, builder)], { + cwd: REPO_ROOT, + stdio: 'inherit', + }) + } catch { + // The build tool for the asset is missing or broken; fall back to the + // loud skip rather than failing a suite the machine cannot support. + return emitterAssetSkipReason(tool, assetPath) + } + return emitterAssetSkipReason(tool, assetPath) +} + export function enforceOrAnnounceSkip(reason: string): void { if (compatIsRequired()) { throw new Error(reason) diff --git a/test/repo/compat/option-precedence.test.mts b/test/repo/compat/option-precedence.test.mts new file mode 100644 index 00000000..c8d4e353 --- /dev/null +++ b/test/repo/compat/option-precedence.test.mts @@ -0,0 +1,76 @@ +// socket-lint: mirror-exempt — an end-to-end suite over build-tool argument +// precedence, not over a TypeScript module. +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { runFactsGeneration } from '../../../src/run/run-facts-generation.mts' +import { + compatEnv, + enforceOrAnnounceSkip, + findBuildToolBin, + skipReasonFor, +} from './lib/toolchain.mts' + +// Every build tool resolves a repeated property last-one-wins — measured on +// maven `-D` and gradle `-P`. This package therefore passes its own properties +// AFTER the caller's opts, so a caller cannot unload the emitter. +// +// The case here is the one a collision check cannot catch: maven accepts +// `--define` as a synonym for `-D`, which matches no `-D` pattern. Before the +// ordering fix this run emitted nothing and still exited 0 — an empty SBOM that +// reads downstream as "no dependencies". +const POM = ` + 4.0.0 + com.example + precedence + 1.0.0 + jar + +` + +let projectDir: string | undefined +let cleanup: (() => Promise) | undefined + +describe('this package own properties outrank caller opts', () => { + beforeAll(async () => { + const reason = skipReasonFor('maven') + if (reason) { + enforceOrAnnounceSkip(reason) + return + } + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-facts-prec-')) + projectDir = path.join(root, 'project') + await fs.mkdir(projectDir, { recursive: true }) + await fs.writeFile(path.join(projectDir, 'pom.xml'), POM) + cleanup = async () => { + await fs.rm(root, { force: true, recursive: true }) + } + }, 300_000) + + afterAll(async () => { + await cleanup?.() + }) + + it('keeps the extension loaded when a caller aliases maven.ext.class.path', async () => { + if (!projectDir) { + return + } + const result = await runFactsGeneration({ + bin: findBuildToolBin('maven')!, + cwd: projectDir, + env: compatEnv(), + // `--define` is `-D`. A collision check keyed on `-D` never sees it. + opts: ['--define', 'maven.ext.class.path=/nonexistent-socket-facts.jar'], + tool: 'maven', + }) + + expect(result.code).toBe(0) + // An unloaded extension emits no records at all, so a populated project + // list is the proof that ours won. + expect(result.facts.projects?.length ?? 0).toBeGreaterThan(0) + expect(result.facts.projects?.[0]?.name).toBe('precedence') + }, 300_000) +}) diff --git a/test/repo/compat/workspace-enumeration.test.mts b/test/repo/compat/workspace-enumeration.test.mts new file mode 100644 index 00000000..0a5103ac --- /dev/null +++ b/test/repo/compat/workspace-enumeration.test.mts @@ -0,0 +1,101 @@ +// socket-lint: mirror-exempt — an end-to-end suite over the workspaces +// EMITTER, not over a TypeScript module. +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { enumerateWorkspaces } from '../../../src/run/workspace-enumeration.mts' +import { + compatEnv, + enforceOrAnnounceSkip, + findBuildToolBin, + skipReasonFor, +} from './lib/toolchain.mts' + +import type { WorkspaceEnumerationResult } from '../../../src/run/workspace-enumeration.mts' + +// Workspace enumeration is a SECOND emitter family sharing one jar and one +// invocation path with facts generation. It resolves nothing, so a wrong task +// gate or a wrong plugin filename yields an empty project list rather than an +// error — which is why this drives a real build instead of asserting on args. +// The fixture is the two-module tree the exclude-paths suite uses. +const FIXTURE_SOURCE_DIR = path.join( + import.meta.dirname, + 'exclude-paths-gradle', +) + +let workspace: { projectDir: string; gradleUserHome: string } | undefined +let cleanup: (() => Promise) | undefined + +async function enumerate( + excludePaths?: string[] | undefined, +): Promise { + return await enumerateWorkspaces({ + bin: findBuildToolBin('gradle')!, + cwd: workspace!.projectDir, + env: compatEnv(), + opts: ['--offline', '-g', workspace!.gradleUserHome], + tool: 'gradle', + ...(excludePaths ? { excludePaths } : {}), + }) +} + +function names(result: WorkspaceEnumerationResult): string[] { + return result.projects.map(p => p.name).toSorted() +} + +describe('workspace enumeration', () => { + beforeAll(async () => { + const reason = skipReasonFor('gradle') + if (reason) { + enforceOrAnnounceSkip(reason) + return + } + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-facts-ws-')) + const projectDir = path.join(root, 'project') + await fs.cp(FIXTURE_SOURCE_DIR, projectDir, { recursive: true }) + workspace = { gradleUserHome: path.join(root, 'gradle-home'), projectDir } + cleanup = async () => { + await fs.rm(root, { force: true, recursive: true }) + } + }) + + afterAll(async () => { + await cleanup?.() + }) + + it('lists every module without resolving dependencies', async () => { + if (!workspace) { + return + } + const result = await enumerate() + expect(result.code).toBe(0) + expect(names(result)).toStrictEqual([ + 'app', + 'exclude-paths-fixture', + 'legacy', + ]) + }) + + it('honours excludePaths, the same as the facts emitters', async () => { + if (!workspace) { + return + } + expect(names(await enumerate(['legacy']))).toStrictEqual([ + 'app', + 'exclude-paths-fixture', + ]) + }) + + it('reports each module with a purl identity, not just a name', async () => { + if (!workspace) { + return + } + const app = (await enumerate()).projects.find(p => p.name === 'app') + expect(app?.type).toBe('maven') + expect(app?.namespace).toBe('com.example') + expect(app?.version).toBe('1.0.0') + }) +}) diff --git a/test/repo/integration/maven-extension-jar.test.mts b/test/repo/integration/maven-extension-jar.test.mts new file mode 100644 index 00000000..45aef1c9 --- /dev/null +++ b/test/repo/integration/maven-extension-jar.test.mts @@ -0,0 +1,71 @@ +// The facts and workspaces participants ship in ONE jar. If the build ever +// emits them separately, a consumer loading only the facts jar silently loses +// workspace enumeration — and a Maven run with no participant emits an empty +// SBOM, which reads downstream as "no dependencies" rather than as an error. +// So this asserts the packaged bytes, not the source tree. +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +import { mavenExtensionJarPath } from '../../../src/assets.mts' + +const PARTICIPANTS = [ + 'dev.socket.facts.SocketFactsLifecycleParticipant', + 'dev.socket.facts.SocketWorkspacesLifecycleParticipant', +] + +function jarEntry(jarPath: string, entry: string): string { + return execFileSync('unzip', ['-p', jarPath, entry], { encoding: 'utf8' }) +} + +function skipReason(): string | undefined { + if (!existsSync(mavenExtensionJarPath())) { + return ( + `No maven emitter asset. Where: ${mavenExtensionJarPath()}. ` + + `Saw no file, wanted the built extension jar. ` + + `Fix: run \`pnpm run build:maven-extension\` (needs a JDK).` + ) + } + return undefined +} + +describe('the maven extension jar', () => { + it('registers both lifecycle participants in its sisu index', () => { + const reason = skipReason() + if (reason) { + // oxlint-disable-next-line socket/no-console-prefer-logger -- a test-runner warning belongs on the runner's own stream, not a product logger. + console.warn(`SKIPPED JAR CHECK: ${reason}`) + return + } + // Sisu discovers a participant only if it is in this index; a class present + // in the jar but absent here is never loaded. + const index = jarEntry( + mavenExtensionJarPath(), + 'META-INF/sisu/javax.inject.Named', + ) + for (const participant of PARTICIPANTS) { + expect(index, `sisu index is missing ${participant}`).toContain( + participant, + ) + } + }) + + it('carries both participant classes and the shared support class', () => { + const reason = skipReason() + if (reason) { + // oxlint-disable-next-line socket/no-console-prefer-logger -- a test-runner warning belongs on the runner's own stream, not a product logger. + console.warn(`SKIPPED JAR CHECK: ${reason}`) + return + } + const listing = execFileSync('unzip', ['-l', mavenExtensionJarPath()], { + encoding: 'utf8', + }) + for (const participant of PARTICIPANTS) { + expect(listing).toContain(`${participant.replaceAll('.', '/')}.class`) + } + // One copy of the shared helpers, in the same jar as both participants: + // two shaded copies on one ext classpath would let load order decide. + expect(listing).toContain('dev/socket/facts/SocketSupport.class') + }) +}) diff --git a/test/repo/records-parser.fuzz.ts b/test/repo/records-parser.fuzz.ts index 2e2ab2f5..61443eb9 100644 --- a/test/repo/records-parser.fuzz.ts +++ b/test/repo/records-parser.fuzz.ts @@ -24,6 +24,8 @@ import { } from '../../src/pipeline/sidecar.mts' import { renderResolutionErrorReport } from '../../src/report/render.mts' +const FACTS_FILE = '/fuzz/.socket.facts.json' + fuzz('the records parser never throws on arbitrary bytes', data => { const parsed = parseRecords(data.toString('utf8')) for (const [rootId, root] of parsed.roots) { @@ -69,8 +71,9 @@ fuzz('the sidecar accumulator always emits a contract-valid payload', data => { parseRecords(data.toString('utf8')), ) const acc = createSidecarAccumulator() - accumulateSidecar(acc, facts, artifactPaths) - const result = validateResolvedPathsSidecar(serializeSidecar(acc)) + accumulateSidecar(acc, facts, artifactPaths, FACTS_FILE) + const sidecar = serializeSidecar(acc) + const result = validateResolvedPathsSidecar(sidecar) if (!result.ok) { throw new Error( `sidecar accumulator emitted a payload the strict consumer would reject: ${result.violations @@ -78,6 +81,15 @@ fuzz('the sidecar accumulator always emits a contract-valid payload', data => { .join('; ')}`, ) } + // The payload is keyed by the facts file its entries describe, and the + // validator accepts ANY string key — so without this the fuzz would pass on a + // payload keyed by literal "undefined" and guard nothing about the contract. + const keys = Object.keys(sidecar) + if (keys.length !== 1 || keys[0] !== FACTS_FILE) { + throw new Error( + `sidecar must be keyed by the facts file it describes: saw ${JSON.stringify(keys)}, wanted [${JSON.stringify(FACTS_FILE)}]`, + ) + } }) fuzz('the resolution report never throws on arbitrary failure text', data => { diff --git a/test/repo/unit/artifact-paths.test.mts b/test/repo/unit/artifact-paths.test.mts new file mode 100644 index 00000000..9ff08442 --- /dev/null +++ b/test/repo/unit/artifact-paths.test.mts @@ -0,0 +1,64 @@ +// socket-lint: mirror-exempt — asserts a buildArtifactPaths behaviour that only +// manifests through the real records → assemble → sidecar path, so it drives +// three modules to reach one decision in artifact-paths.mts. +// A coordinate can be BOTH a resolved dependency node and a first-party +// project: a module that publishes under the same GAV it builds. Which paths +// win decides what per-subproject reachability analyses, and the two answers +// are indistinguishable in aggregate output — so it is pinned here. +import { describe, expect, it } from 'vitest' + +import { assembleFacts } from '../../../src/pipeline/assemble.mts' +import { parseRecords } from '../../../src/pipeline/records.mts' +import { + accumulateSidecar, + createSidecarAccumulator, + serializeSidecar, +} from '../../../src/pipeline/sidecar.mts' + +// `:app` is a first-party module with its own output dir, AND appears as a +// resolved node whose artifact is a published jar of that same module. +const SELF_PUBLISHING_RECORDS = [ + 'meta\tgradle\t8.0\t17', + 'project\t:app\tcom.example\tapp\t1.0\t/abs/app', + 'projectSrc\t:app\t/abs/app/src/main/java', + 'projectTgt\t:app\t/abs/app/build/classes', + 'root\tr1\t:app\truntimeClasspath\t1', + 'node\tr1\tcom.example:app:jar:1.0\tcom.example\tapp\t1.0\tjar\t\t1', + 'file\tr1\tcom.example:app:jar:1.0\t/abs/published/app.jar', + 'scanned\truntimeClasspath', +].join('\n') + +const FACTS_FILE = '/abs/.socket.facts.json' + +describe('buildArtifactPaths for a self-publishing module', () => { + it('reports the project output roots, not the published jar', () => { + const { artifactPaths, facts } = assembleFacts( + parseRecords(SELF_PUBLISHING_RECORDS), + { fileExists: () => true }, + ) + const acc = createSidecarAccumulator() + accumulateSidecar(acc, facts, artifactPaths, FACTS_FILE) + const bucket = serializeSidecar(acc)[FACTS_FILE]! + + // Adding the jar alongside the classes would show the analyser two copies + // of the same classes. + expect(bucket.projects[0]!.targets).toEqual(['/abs/app/build/classes']) + expect(bucket.projects[0]!.targets).not.toContain('/abs/published/app.jar') + for (const component of bucket.components) { + expect(component.targets).not.toContain('/abs/published/app.jar') + } + }) + + it('still reports source roots for that module', () => { + const { artifactPaths, facts } = assembleFacts( + parseRecords(SELF_PUBLISHING_RECORDS), + { fileExists: () => true }, + ) + const acc = createSidecarAccumulator() + accumulateSidecar(acc, facts, artifactPaths, FACTS_FILE) + + expect(serializeSidecar(acc)[FACTS_FILE]!.projects[0]!.sources).toEqual([ + '/abs/app/src/main/java', + ]) + }) +}) diff --git a/test/repo/unit/assemble.test.mts b/test/repo/unit/assemble.test.mts index b82bc24c..e7582fb7 100644 --- a/test/repo/unit/assemble.test.mts +++ b/test/repo/unit/assemble.test.mts @@ -26,6 +26,8 @@ const RECORDS = [ 'scanned\truntimeClasspath', ].join('\n') +const FACTS_FILE = '/abs/.socket.facts.json' + describe('records → assemble → sidecar', () => { it('carries first-party project paths, external jars, and artifactless BOMs', () => { // Inject fileExists so the synthetic absolute paths aren't filtered out. @@ -40,28 +42,35 @@ describe('records → assemble → sidecar', () => { expect(facts.metadata).not.toHaveProperty('schemaVersion') const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, artifactPaths) - const byName = new Map(serializeSidecar(acc).map(r => [r.name, r])) - - // First-party module: project-only (not a node), yet its source/output - // roots reach the sidecar. - expect(byName.get('app')).toEqual({ - group: 'com.example', - name: 'app', - version: '1.0', - ext: '', - classifier: null, - ecosystem: 'maven', - targets: ['/abs/app/build/classes'], - sources: ['/abs/app/src/main/java'], - }) + accumulateSidecar(acc, facts, artifactPaths, FACTS_FILE) + const bucket = serializeSidecar(acc)[FACTS_FILE]! + const componentsByName = new Map(bucket.components.map(c => [c.name, c])) + + // First-party module: a project (not a node), yet its source/output roots + // reach the sidecar. + expect(bucket.projects).toEqual([ + { + type: 'maven', + namespace: 'com.example', + name: 'app', + version: '1.0', + subprojectDir: '/abs/app', + dependencies: ['com.example:bom:2.0', 'com.example:lib:jar:1.0'], + resolvedAs: [], + targets: ['/abs/app/build/classes'], + sources: ['/abs/app/src/main/java'], + }, + ]) // External dependency: jar target, no sources. - expect(byName.get('lib')?.targets).toEqual(['/abs/lib.jar']) - expect(byName.get('lib')?.sources).toEqual([]) + expect(componentsByName.get('lib')?.targets).toEqual(['/abs/lib.jar']) + expect(componentsByName.get('lib')?.sources).toEqual([]) // Artifactless BOM: present with empty arrays (resolved, no artifact). - expect(byName.get('bom')).toMatchObject({ targets: [], sources: [] }) + expect(componentsByName.get('bom')).toMatchObject({ + targets: [], + sources: [], + }) }) }) diff --git a/test/repo/unit/invoke-build-tool.test.mts b/test/repo/unit/invoke-build-tool.test.mts new file mode 100644 index 00000000..42598931 --- /dev/null +++ b/test/repo/unit/invoke-build-tool.test.mts @@ -0,0 +1,94 @@ +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { afterEach, describe, expect, it } from 'vitest' + +import { + SBT_PLUGIN_FILENAME, + sbtPluginSourcePath, +} from '../../../src/assets.mts' +import { invokeSbt } from '../../../src/run/invoke-build-tool.mts' + +import type { FactsGenerationOptions } from '../../../src/run/invocation.mts' + +const created: string[] = [] + +afterEach(async () => { + while (created.length) { + await fs.rm(created.pop()!, { force: true, recursive: true }) + } +}) + +async function globalBase(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-facts-owned-')) + created.push(dir) + return dir +} + +// The build tool never runs here: `process.execPath` is a real absolute +// executable that rejects sbt's arguments, and the invocation layer is +// never-throw, so the call returns (or rejects on the absent records file) +// without needing a JVM. What is under test is which directory the plugin was +// written into, and whether that directory survives. +function options( + overrides: Partial, +): FactsGenerationOptions { + return { + bin: process.execPath, + cwd: path.resolve('/'), + env: {}, + opts: [], + tool: 'sbt', + ...overrides, + } +} + +async function runSbtInvocation(config: FactsGenerationOptions): Promise { + try { + await invokeSbt( + config, + sbtPluginSourcePath(), + SBT_PLUGIN_FILENAME, + 'socketFacts', + 'socket-sbt-facts-', + ) + } catch { + // The fake bin emits no records; the paths under test are already written. + } +} + +// sbt provisions the Scala toolchain under `/boot`, and withFiles' +// artifactPaths point into it. An ephemeral base deleted before the call +// returns therefore hands back paths that no longer exist — a scan that +// silently under-resolves rather than failing. +describe('invokeSbt with a caller-owned global base', () => { + it('runs sbt against the supplied directory, not an ephemeral one', async () => { + const dir = await globalBase() + + await runSbtInvocation(options({ tmpDir: dir })) + + // The plugin lands in the caller's dir only if the supplied base was used. + await expect( + fs.access(path.join(dir, 'plugins', SBT_PLUGIN_FILENAME)), + ).resolves.toBeUndefined() + }) + + it('leaves the supplied directory in place for the caller to delete', async () => { + const dir = await globalBase() + + await runSbtInvocation(options({ tmpDir: dir })) + + await expect(fs.access(dir)).resolves.toBeUndefined() + }) + + it('writes nothing into the caller-owned dir when none is supplied', async () => { + const dir = await globalBase() + + await runSbtInvocation(options({})) + + // An ephemeral base is used and cleaned up; the caller's dir is untouched. + expect(await fs.readdir(dir)).toEqual([]) + }) +}) diff --git a/test/repo/unit/parity/assemble.test.mts b/test/repo/unit/parity/assemble.test.mts new file mode 100644 index 00000000..7fb92495 --- /dev/null +++ b/test/repo/unit/parity/assemble.test.mts @@ -0,0 +1,82 @@ +/* + * @file Verbatim port of socket-cli's own `assemble.test.mts`, adapted ONLY for + * import paths. socket-cli swaps its vendored emitters for this package, + * and that swap has to be a no-op, so its suite is the oracle: an + * assertion edited to make this file pass is a divergence, not a test fix. + * Record the reason on the tracking issue before touching one. + */ +import { describe, expect, it } from 'vitest' + +import { assembleFacts } from '../../../../src/pipeline/assemble.mts' +import { parseRecords } from '../../../../src/pipeline/records.mts' +import { + accumulateSidecar, + serializeSidecar, +} from '../../../../src/pipeline/sidecar.mts' + +import type { SidecarAccumulator } from '../../../../src/pipeline/sidecar.mts' + +// Minimal line-protocol records for a one-module Gradle build (--with-files): +// - first-party module `:app` (a project, NOT a dependency node) with source + +// output roots, +// - an external dep `lib` resolved to a jar, +// - a `bom` resolved as a constraints-only artifact (no file). +const RECORDS = [ + 'meta\tgradle\t8.0\t17', + 'project\t:app\tcom.example\tapp\t1.0\t/abs/app', + 'projectSrc\t:app\t/abs/app/src/main/java', + 'projectTgt\t:app\t/abs/app/build/classes', + 'root\tr1\t:app\truntimeClasspath\t1', + 'node\tr1\tcom.example:lib:jar:1.0\tcom.example\tlib\t1.0\tjar\t\t1', + 'node\tr1\tcom.example:bom:2.0\tcom.example\tbom\t2.0\t\t\t1', + 'file\tr1\tcom.example:lib:jar:1.0\t/abs/lib.jar', + 'scanned\truntimeClasspath', +].join('\n') + +describe('records → assemble → sidecar', () => { + it('carries first-party project paths, external jars, and artifactless BOMs', () => { + // Inject fileExists so the synthetic absolute paths aren't filtered out. + const { artifactPaths, facts } = assembleFacts(parseRecords(RECORDS), { + fileExists: () => true, + }) + + expect(facts.metadata?.tool).toBe('gradle') + expect(facts.metadata?.javaVersion).toBe('17') + // contentHash/schemaVersion are intentionally absent from metadata. + expect(facts.metadata).not.toHaveProperty('contentHash') + expect(facts.metadata).not.toHaveProperty('schemaVersion') + + const acc: SidecarAccumulator = new Map() + accumulateSidecar(acc, facts, artifactPaths, '/abs/.socket.facts.json') + const resolved = serializeSidecar(acc) + const bucket = resolved['/abs/.socket.facts.json']! + const byName = new Map(bucket.components.map(r => [r.name, r])) + + // First-party module: project-only (not a node), yet its source/output + // roots reach the sidecar, keyed by its own facts file. + expect(bucket.projects).toEqual([ + { + type: 'maven', + namespace: 'com.example', + name: 'app', + version: '1.0', + subprojectDir: '/abs/app', + dependencies: ['com.example:bom:2.0', 'com.example:lib:jar:1.0'], + resolvedAs: [], + targets: ['/abs/app/build/classes'], + sources: ['/abs/app/src/main/java'], + }, + ]) + + // External dependency: jar target, empty (not undefined) sources - it was + // resolved, it just has no first-party source roots. + expect(byName.get('lib')?.targets).toEqual(['/abs/lib.jar']) + expect(byName.get('lib')?.sources).toEqual([]) + + // Artifactless BOM: present with explicit empty arrays (resolved, no + // artifact) - [] means resolved-and-empty, not "not resolved". + const bom = byName.get('bom') + expect(bom?.targets).toEqual([]) + expect(bom?.sources).toEqual([]) + }) +}) diff --git a/test/repo/unit/parity/exclude-paths-glob.test.mts b/test/repo/unit/parity/exclude-paths-glob.test.mts new file mode 100644 index 00000000..0617dc76 --- /dev/null +++ b/test/repo/unit/parity/exclude-paths-glob.test.mts @@ -0,0 +1,198 @@ +/* + * @file Verbatim port of socket-cli's own `exclude-paths-glob.test.mts`, + * adapted ONLY for the import path. socket-cli swaps its vendored emitters + * for this package, and that swap has to be a no-op, so its suite + * is the oracle: an assertion edited to make this file pass is a semantics + * divergence, not a test fix. Record the reason on the + * tracking issue before touching one. + */ +import { describe, expect, it } from 'vitest' + +import { + createExcludePathFilter, + excludePathGlobToRegexSource, + serializeExcludePathPatterns, +} from '../../../../src/run/exclude-paths-glob.mts' + +// Vector table for the cross-language --exclude-paths contract. Globs are +// compiled to regex pattern sources here (the ONLY implementation) and handed +// pre-compiled to the gradle/sbt/maven facts and workspaces scripts, so these +// vectors define the semantics every producer sees. Every entry means "this +// path AND its whole subtree", whether or not the user wrote a trailing +// `/**` themselves. +const MATCH_VECTORS: Array<{ + glob: string + matches: string[] + rejects: string[] +}> = [ + // A bare entry excludes itself and its whole subtree, without needing an + // explicit trailing `/**`. + { + glob: 'legacy', + matches: ['legacy', 'legacy/sub', 'legacy/sub/deeper'], + rejects: ['legacyx', 'xlegacy', 'a/legacy'], + }, + // An explicit trailing `/**` (or `/`) compiles to the identical pattern. + { + glob: 'legacy/**', + matches: ['legacy', 'legacy/sub'], + rejects: ['legacyx'], + }, + { + glob: 'legacy/', + matches: ['legacy', 'legacy/sub'], + rejects: ['legacyx'], + }, + // `*` spans one path segment, never crossing `/`. + { + glob: 'leg*cy', + matches: ['legacy', 'legcy'], + rejects: ['leg/acy', 'legacyx'], + }, + // `?` matches exactly one character, never `/`. + { + glob: 'a?c', + matches: ['abc'], + rejects: ['ac', 'a/c', 'abbc'], + }, + // Leading `**/` matches at any depth, including root-level (the NIO + // zero-depth gap this compiler exists to close without per-language + // variant expansion). + { + glob: '**/legacy', + matches: ['legacy', 'a/legacy', 'a/b/legacy', 'a/legacy/sub'], + rejects: ['legacyx', 'a/legacyx'], + }, + // A mid-pattern `**` spans zero or more whole segments. + { + glob: 'src/**/legacy', + matches: ['src/legacy', 'src/a/legacy', 'src/a/b/legacy'], + rejects: ['xsrc/legacy', 'src/legacyx', 'legacy'], + }, + // Character classes: enumerations, ranges, and `[!..]`/`[^..]` negation. + { + glob: '[lL]egacy', + matches: ['legacy', 'Legacy'], + rejects: ['regacy'], + }, + { + glob: 'v[1-3]', + matches: ['v1', 'v2', 'v3'], + rejects: ['v4'], + }, + { + glob: '[!x]legacy', + matches: ['ylegacy'], + rejects: ['xlegacy'], + }, + { + glob: '[^x]legacy', + matches: ['ylegacy'], + rejects: ['xlegacy'], + }, + // Regex metacharacters in globs are literals. + { + glob: 'a.b', + matches: ['a.b'], + rejects: ['axb'], + }, + // An unterminated `[` is a literal bracket. + { + glob: 'a[bc', + matches: ['a[bc'], + rejects: ['ab', 'ac'], + }, + // An empty (possibly negated) class would emit `[^]`, valid in JS but + // rejected by Java - the whole glob falls back to a literal match. + { + glob: '[!]legacy', + matches: ['[!]legacy'], + rejects: ['xlegacy', 'legacy'], + }, + // `&` inside a class is a literal (Java classes support `&&` intersection; + // the emitted pattern escapes it so both engines agree). + { + glob: '[a&]x', + matches: ['ax', '&x'], + rejects: ['bx'], + }, +] + +describe('exclude-paths-glob vectors (cross-language contract)', () => { + for (const { glob, matches, rejects } of MATCH_VECTORS) { + it(`\`${glob}\``, () => { + const filter = createExcludePathFilter([glob]) + for (const relPath of matches) { + expect(filter(relPath), `${glob} should match ${relPath}`).toBe(true) + } + for (const relPath of rejects) { + expect(filter(relPath), `${glob} should reject ${relPath}`).toBe(false) + } + }) + } +}) + +describe('multiple entries', () => { + it('matches if any entry matches', () => { + const filter = createExcludePathFilter(['legacy', 'vendor']) + expect(filter('legacy')).toBe(true) + expect(filter('vendor/pkg')).toBe(true) + expect(filter('src')).toBe(false) + }) + + it('an empty/undefined list matches nothing', () => { + expect(createExcludePathFilter([])('legacy')).toBe(false) + expect(createExcludePathFilter(undefined)('legacy')).toBe(false) + }) +}) + +describe('emitted pattern sources (transport format)', () => { + it('wraps every entry in a self-or-subtree suffix', () => { + expect(excludePathGlobToRegexSource('legacy')).toBe('^(?:legacy)(?:/.*)?$') + }) + + it('translates * and ? to segment-bounded classes', () => { + expect(excludePathGlobToRegexSource('a*b')).toBe('^(?:a[^/]*b)(?:/.*)?$') + expect(excludePathGlobToRegexSource('a?b')).toBe('^(?:a[^/]b)(?:/.*)?$') + }) + + it('translates a leading ** to zero-or-more full segments', () => { + expect(excludePathGlobToRegexSource('**/legacy')).toBe( + '^(?:(?:[^/]+/)*legacy)(?:/.*)?$', + ) + }) + + it('escapes regex metacharacters as literals', () => { + expect(excludePathGlobToRegexSource('a.b')).toBe('^(?:a\\.b)(?:/.*)?$') + }) + + it('escapes `&` in class bodies (Java `&&` intersection guard)', () => { + expect(excludePathGlobToRegexSource('[a&]x')).toBe('^(?:[a\\&]x)(?:/.*)?$') + }) + + it('normalizes `[!..]` negation to `[^..]`', () => { + expect(excludePathGlobToRegexSource('[!x]legacy')).toBe( + '^(?:[^x]legacy)(?:/.*)?$', + ) + }) + + it('never emits `[^]` (Java-invalid); empty classes go literal', () => { + expect(excludePathGlobToRegexSource('[!]legacy')).toBe( + '^(?:\\[!\\]legacy)(?:/.*)?$', + ) + }) + + it('emits nothing a comma-join could break on', () => { + // The transport comma-joins patterns; globs cannot contain commas (the + // comma-split precedes glob parsing), so emitted patterns cannot either. + const serialized = serializeExcludePathPatterns(['legacy', '[a&]x']) + for (const pattern of serialized.split(',')) { + expect(() => new RegExp(pattern)).not.toThrow() + } + }) + + it('serializes an empty/undefined list to the empty string', () => { + expect(serializeExcludePathPatterns([])).toBe('') + expect(serializeExcludePathPatterns(undefined)).toBe('') + }) +}) diff --git a/test/repo/unit/parity/render.test.mts b/test/repo/unit/parity/render.test.mts new file mode 100644 index 00000000..a3abc0c2 --- /dev/null +++ b/test/repo/unit/parity/render.test.mts @@ -0,0 +1,193 @@ +/* + * @file Verbatim port of socket-cli's own `resolution-report-render.test.mts`, adapted ONLY for + * import paths. socket-cli swaps its vendored emitters for this package, + * and that swap has to be a no-op, so its suite is the oracle: an + * assertion edited to make this file pass is a divergence, not a test fix. + * Record the reason on the tracking issue before touching one. + */ +import { describe, expect, it } from 'vitest' + +import { renderResolutionErrorReport } from '../../../../src/report/render.mts' + +import type { ResolutionFailure } from '../../../../src/report/report-types.mts' + +const f = ( + coord: string, + detail: string, + config = 'runtimeClasspath', +): ResolutionFailure => ({ + coord, + detail, + config, +}) + +describe('resolution failure classification', () => { + it('classifies a Gradle registry miss as blocking not-found', () => { + const r = renderResolutionErrorReport( + [f('com.example:missing:1.0', 'Could not find com.example:missing:1.0.')], + ['runtimeClasspath'], + 'gradle', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Not found in any repository') + expect(r.nonBlockingNotice).toBe('') + }) + + it('treats Gradle variant ambiguity as non-blocking (notice only, no summary)', () => { + const r = renderResolutionErrorReport( + [ + f( + 'com.example:amb:1.0', + 'Cannot choose between the following variants of com.example:amb:1.0', + ), + ], + ['runtimeClasspath'], + 'gradle', + ) + expect(r.hasBlockingFailures).toBe(false) + expect(r.summary).toBe('') + expect(r.nonBlockingNotice).toContain('ambiguous variant') + }) + + it("classifies Gradle's 'unable to find a matching variant' phrasing as blocking no-matching-variant", () => { + const r = renderResolutionErrorReport( + [ + f( + 'com.example:lib:1.0', + "Unable to find a matching variant of com.example:lib:1.0:\n - Variant 'apiElements'", + ), + ], + ['runtimeClasspath'], + 'gradle', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('No compatible variant') + expect(r.nonBlockingNotice).toBe('') + }) + + it('classifies a Gradle capability conflict as blocking', () => { + const r = renderResolutionErrorReport( + [ + f( + 'com.google.collections:google-collections:1.0', + 'Conflict on capability com.google.collections:google-collections', + ), + ], + ['runtimeClasspath'], + 'gradle', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Capability conflict') + }) + + it('surfaces both a blocking failure and a non-blocking notice together', () => { + const r = renderResolutionErrorReport( + [ + f('com.example:missing:1.0', 'Could not find com.example:missing:1.0.'), + f( + 'com.example:amb:1.0', + 'Cannot choose between the following variants', + 'testRuntime', + ), + ], + ['runtimeClasspath', 'testRuntime'], + 'gradle', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Not found in any repository') + expect(r.nonBlockingNotice).toContain('ambiguous variant') + }) + + it('classifies an sbt/Ivy unresolved dependency as blocking not-found', () => { + const r = renderResolutionErrorReport( + [ + f( + 'com.example:missing:1.0', + 'unresolved dependency: com.example#missing;1.0: not found', + ), + ], + ['compile'], + 'sbt', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Not found in any repository') + // Ivy has no variant categories. + expect(r.nonBlockingNotice).toBe('') + }) + + it('classifies a Maven artifact miss as blocking not-found', () => { + const r = renderResolutionErrorReport( + [ + f( + 'com.example:missing:jar:1.0', + 'Could not find artifact com.example:missing:jar:1.0', + 'compile', + ), + ], + ['compile'], + 'maven', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Not found in any repository') + }) + + it('reports blocking failures as ignored (not fatal) under ignoreUnresolved', () => { + const r = renderResolutionErrorReport( + [f('com.example:missing:1.0', 'Could not find com.example:missing:1.0.')], + ['runtimeClasspath'], + 'gradle', + { ignoreUnresolved: true }, + ) + // hasBlockingFailures still reflects the classification; the caller decides. + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Ignored') + expect(r.summary).not.toContain('To proceed, re-run') + }) + + it('returns an empty report when there are no failures', () => { + const r = renderResolutionErrorReport([], ['runtimeClasspath'], 'gradle') + expect(r.hasBlockingFailures).toBe(false) + expect(r.summary).toBe('') + expect(r.nonBlockingNotice).toBe('') + }) + + it('treats an ambiguity-driven config throw as a non-blocking notice', () => { + const r = renderResolutionErrorReport( + [], + ['debugAndroidTestCompileClasspath'], + 'gradle', + { + unscannable: [ + { + config: 'debugAndroidTestCompileClasspath', + detail: + 'Cannot choose between the following variants of com.example:foo:1.0', + }, + ], + }, + ) + expect(r.hasBlockingFailures).toBe(false) + expect(r.summary).toBe('') + expect(r.nonBlockingNotice).toContain('Could not scan 1 configuration(s)') + expect(r.details).toContain('debugAndroidTestCompileClasspath') + }) + + it('treats a non-ambiguity config throw as a blocking failure', () => { + const r = renderResolutionErrorReport([], ['runtimeClasspath'], 'gradle', { + unscannable: [ + { + config: 'runtimeClasspath', + detail: + 'Could not GET https://repo.example/foo. Received status code 401', + }, + ], + }) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Could not scan 1 configuration(s)') + expect(r.summary).toContain('runtimeClasspath') + expect(r.nonBlockingNotice).toBe('') + // With no per-dep failures the summary must not lead with a blank line + // (which would render as a dangling ✗ under logger.fail). + expect(r.summary.startsWith('\n')).toBe(false) + }) +}) diff --git a/test/repo/unit/parity/sidecar.test.mts b/test/repo/unit/parity/sidecar.test.mts new file mode 100644 index 00000000..d30310fa --- /dev/null +++ b/test/repo/unit/parity/sidecar.test.mts @@ -0,0 +1,331 @@ +/* + * @file Verbatim port of socket-cli's own `sidecar.test.mts`, adapted ONLY for + * import paths. socket-cli swaps its vendored emitters for this package, + * and that swap has to be a no-op, so its suite is the oracle: an + * assertion edited to make this file pass is a wire-format divergence, not a + * test fix. Record the reason on the tracking issue before touching one. + */ +import { describe, expect, it } from 'vitest' + +import { + accumulateSidecar, + hasResolvedPathsSidecarEntries, + hasSidecarEntries, + mergeResolvedPathsSidecars, + serializeSidecar, +} from '../../../../src/pipeline/sidecar.mts' + +import type { SocketFactsSbom } from '../../../../src/contract/sbom.mts' +import type { ResolvedArtifactPaths } from '../../../../src/contract/sidecar.mts' +import type { SidecarAccumulator } from '../../../../src/pipeline/sidecar.mts' + +function emptyArtifactPaths(): ResolvedArtifactPaths { + return { + targetsByCoord: new Map(), + targetsByGav: new Map(), + sourcesByCoord: new Map(), + coords: new Set(), + } +} + +function mkComponentFixture(target: string): { + facts: SocketFactsSbom + paths: ResolvedArtifactPaths +} { + const paths = emptyArtifactPaths() + paths.targetsByCoord.set('g:a:jar:1', [target]) + return { + facts: { + components: [ + { + type: 'maven', + namespace: 'g', + name: 'a', + version: '1', + qualifiers: { ext: 'jar' }, + id: 'g:a:jar:1', + }, + ], + }, + paths, + } +} + +describe('compute-artifacts sidecar', () => { + it('carries a component through with resolved targets/sources attached, keyed by its own facts file', () => { + const facts: SocketFactsSbom = { + components: [ + { + type: 'maven', + namespace: 'com.example', + name: 'lib', + version: 'da517db', + qualifiers: { ext: 'jar' }, + id: 'com.example:lib:jar:da517db', + }, + ], + } + const artifactPaths = emptyArtifactPaths() + artifactPaths.targetsByCoord.set('com.example:lib:jar:da517db', [ + '/abs/lib.jar', + ]) + artifactPaths.sourcesByCoord.set('com.example:lib:jar:da517db', [ + '/abs/lib/src/main/java', + ]) + + const acc: SidecarAccumulator = new Map() + accumulateSidecar(acc, facts, artifactPaths, '/root/.socket.facts.json') + const resolved = serializeSidecar(acc) + + expect(resolved).toEqual({ + '/root/.socket.facts.json': { + projects: [], + components: [ + { + type: 'maven', + namespace: 'com.example', + name: 'lib', + version: 'da517db', + qualifiers: { ext: 'jar' }, + id: 'com.example:lib:jar:da517db', + targets: ['/abs/lib.jar'], + sources: ['/abs/lib/src/main/java'], + }, + ], + }, + }) + }) + + it('emits explicit empty targets/sources for a resolved-but-artifactless coord (pom/BOM) - [] means resolved, not "not resolved"', () => { + const facts: SocketFactsSbom = { + components: [ + { + type: 'maven', + namespace: 'com.example', + name: 'bom', + version: '1.0', + qualifiers: { ext: 'pom' }, + id: 'com.example:bom:pom:1.0', + }, + ], + } + const acc: SidecarAccumulator = new Map() + accumulateSidecar( + acc, + facts, + emptyArtifactPaths(), + '/root/.socket.facts.json', + ) + const resolved = serializeSidecar(acc) + + const entry = resolved['/root/.socket.facts.json']!.components[0]! + expect(entry.targets).toEqual([]) + expect(entry.sources).toEqual([]) + }) + + it('leaves targets/sources undefined (not []) when the entry has no computable coordinate at all', () => { + const facts: SocketFactsSbom = { + components: [ + { type: 'maven', namespace: '', name: '', id: 'degenerate' }, + ], + } + const acc: SidecarAccumulator = new Map() + accumulateSidecar( + acc, + facts, + emptyArtifactPaths(), + '/root/.socket.facts.json', + ) + const entry = + serializeSidecar(acc)['/root/.socket.facts.json']!.components[0]! + expect(entry.targets).toBeUndefined() + expect(entry.sources).toBeUndefined() + }) + + it('preserves the original component fields (id, qualifiers) untouched', () => { + const facts: SocketFactsSbom = { + components: [ + { + type: 'maven', + namespace: 'g', + name: 'a', + version: '1', + qualifiers: { ext: 'jar', classifier: 'sources' }, + id: 'g:a:jar:sources:1', + direct: true, + dependencies: ['x'], + }, + ], + } + const acc: SidecarAccumulator = new Map() + accumulateSidecar( + acc, + facts, + emptyArtifactPaths(), + '/root/.socket.facts.json', + ) + const entry = + serializeSidecar(acc)['/root/.socket.facts.json']!.components[0]! + expect(entry.qualifiers?.['classifier']).toBe('sources') + expect(entry.id).toBe('g:a:jar:sources:1') + expect(entry.direct).toBe(true) + expect(entry.dependencies).toEqual(['x']) + }) + + it('carries a first-party module (project, not a component) source/target roots, keyed by its own facts file', () => { + const facts: SocketFactsSbom = { + // The app module is a project but nothing depends on it, so it is absent + // from components — its source roots must still reach the sidecar. + components: [], + projects: [ + { + type: 'maven', + namespace: 'com.example', + name: 'app', + version: '1.0', + subprojectDir: 'app', + dependencies: [], + resolvedAs: [], + }, + ], + } + const artifactPaths = emptyArtifactPaths() + artifactPaths.sourcesByCoord.set('com.example:app:1.0', [ + '/abs/app/src/main/java', + ]) + artifactPaths.targetsByCoord.set('com.example:app:1.0', [ + '/abs/app/build/classes', + ]) + + const acc: SidecarAccumulator = new Map() + accumulateSidecar(acc, facts, artifactPaths, '/root/app/.socket.facts.json') + const resolved = serializeSidecar(acc) + + expect(resolved['/root/app/.socket.facts.json']!.components).toEqual([]) + expect(resolved['/root/app/.socket.facts.json']!.projects).toEqual([ + { + type: 'maven', + namespace: 'com.example', + name: 'app', + version: '1.0', + subprojectDir: 'app', + dependencies: [], + resolvedAs: [], + targets: ['/abs/app/build/classes'], + sources: ['/abs/app/src/main/java'], + }, + ]) + }) + + it('does NOT reunion the same external coordinate across build roots - duplication across reactors is intentional', () => { + const acc: SidecarAccumulator = new Map() + const a = mkComponentFixture('/root-a/a.jar') + const b = mkComponentFixture('/root-b/a.jar') + accumulateSidecar(acc, a.facts, a.paths, '/root-a/.socket.facts.json') + accumulateSidecar(acc, b.facts, b.paths, '/root-b/.socket.facts.json') + const resolved = serializeSidecar(acc) + + expect( + resolved['/root-a/.socket.facts.json']!.components[0]!.targets, + ).toEqual(['/root-a/a.jar']) + expect( + resolved['/root-b/.socket.facts.json']!.components[0]!.targets, + ).toEqual(['/root-b/a.jar']) + }) + + it('keeps first-party modules from two independent roots fully separate, even with the same purl identity', () => { + const sharedModuleFacts: SocketFactsSbom = { + components: [], + projects: [ + { + type: 'maven', + namespace: 'com.example', + name: 'shared', + version: '1.0', + subprojectDir: '.', + dependencies: [], + resolvedAs: [], + }, + ], + } + const pathsA = emptyArtifactPaths() + pathsA.sourcesByCoord.set('com.example:shared:1.0', [ + '/root-a/src/main/java', + ]) + const pathsB = emptyArtifactPaths() + pathsB.sourcesByCoord.set('com.example:shared:1.0', [ + '/root-b/src/main/java', + ]) + + const acc: SidecarAccumulator = new Map() + accumulateSidecar( + acc, + sharedModuleFacts, + pathsA, + '/root-a/.socket.facts.json', + ) + accumulateSidecar( + acc, + sharedModuleFacts, + pathsB, + '/root-b/.socket.facts.json', + ) + const resolved = serializeSidecar(acc) + + expect(Object.keys(resolved)).toEqual([ + '/root-a/.socket.facts.json', + '/root-b/.socket.facts.json', + ]) + expect( + resolved['/root-a/.socket.facts.json']!.projects[0]!.sources, + ).toEqual(['/root-a/src/main/java']) + expect( + resolved['/root-b/.socket.facts.json']!.projects[0]!.sources, + ).toEqual(['/root-b/src/main/java']) + }) + + it('hasSidecarEntries reports empty until a facts file is accumulated', () => { + const acc: SidecarAccumulator = new Map() + expect(hasSidecarEntries(acc)).toBe(false) + + accumulateSidecar( + acc, + { components: [] }, + emptyArtifactPaths(), + '/root/.socket.facts.json', + ) + expect(hasSidecarEntries(acc)).toBe(true) + }) + + it('mergeResolvedPathsSidecars unions distinct facts-file keys from two already-serialized sidecars', () => { + const accA: SidecarAccumulator = new Map() + accumulateSidecar( + accA, + { components: [] }, + emptyArtifactPaths(), + '/root-a/.socket.facts.json', + ) + const sidecarA = serializeSidecar(accA) + + const accB: SidecarAccumulator = new Map() + accumulateSidecar( + accB, + { components: [] }, + emptyArtifactPaths(), + '/root-b/.socket.facts.json', + ) + const sidecarB = serializeSidecar(accB) + + const merged = mergeResolvedPathsSidecars(sidecarA, sidecarB) + + expect(Object.keys(merged)).toEqual([ + '/root-a/.socket.facts.json', + '/root-b/.socket.facts.json', + ]) + expect(hasResolvedPathsSidecarEntries(merged)).toBe(true) + }) + + it('hasResolvedPathsSidecarEntries reports false for a wholly empty sidecar', () => { + expect(hasResolvedPathsSidecarEntries({})).toBe(false) + }) +}) diff --git a/test/repo/unit/run-facts-generation.test.mts b/test/repo/unit/run-facts-generation.test.mts index afd0222f..278f650a 100644 --- a/test/repo/unit/run-facts-generation.test.mts +++ b/test/repo/unit/run-facts-generation.test.mts @@ -4,6 +4,7 @@ import path from 'node:path' import { describe, expect, it } from 'vitest' +import { emitterProps } from '../../../src/run/invoke-build-tool.mts' import { runFactsGeneration } from '../../../src/run/run-facts-generation.mts' import type { FactsInvocation } from '../../../src/run/invocation.mts' @@ -26,3 +27,28 @@ describe('runFactsGeneration', () => { ).rejects.toThrow(/under-specified/) }) }) + +// The emitters Pattern.compile() what they receive, so what crosses this +// boundary must already be an anchored regex source. A raw glob reaching an +// emitter silently matches nothing. +describe('emitterProps excludePaths', () => { + it('emits compiled anchored pattern sources, not the raw globs', () => { + const props = emitterProps( + { ...invocation(), excludePaths: ['legacy', 'src/**/generated'] }, + '-P', + ) + + expect(props).toContain( + '-Psocket.excludePaths=^(?:legacy)(?:/.*)?$,^(?:src/(?:[^/]+/)*generated)(?:/.*)?$', + ) + }) + + it('omits the property when there are no exclude paths', () => { + expect(emitterProps(invocation(), '-P')).not.toContainEqual( + expect.stringContaining('socket.excludePaths'), + ) + expect( + emitterProps({ ...invocation(), excludePaths: [] }, '-P'), + ).not.toContainEqual(expect.stringContaining('socket.excludePaths')) + }) +}) diff --git a/test/repo/unit/sidecar.test.mts b/test/repo/unit/sidecar.test.mts index 182b988e..131d6bf7 100644 --- a/test/repo/unit/sidecar.test.mts +++ b/test/repo/unit/sidecar.test.mts @@ -1,3 +1,9 @@ +/* + * @file The wire format itself, and the maven paths through it, are covered + * verbatim by socket-cli's own suite in parity/sidecar.test.mts. What lives + * here is what only this package owns: the dotnet emitter's records reaching + * the sidecar with a nuget purl type. + */ import { describe, expect, it } from 'vitest' import { assembleFacts } from '../../../src/pipeline/assemble.mts' @@ -8,184 +14,10 @@ import { serializeSidecar, } from '../../../src/pipeline/sidecar.mts' -import type { - ResolvedArtifactPaths, - SocketFactsSbom, -} from '../../../src/contract/sidecar.mts' -import type { SidecarAccumulator } from '../../../src/pipeline/sidecar.mts' - -function emptyArtifactPaths(): ResolvedArtifactPaths { - return { - targetsByCoord: new Map(), - targetsByGav: new Map(), - sourcesByCoord: new Map(), - coords: new Set(), - } -} - -function mkRootFixture(target: string): { - facts: SocketFactsSbom - paths: ResolvedArtifactPaths -} { - const paths = emptyArtifactPaths() - paths.targetsByCoord.set('g:a:jar:1', [target]) - return { - facts: { - components: [ - { - type: 'maven', - namespace: 'g', - name: 'a', - version: '1', - qualifiers: { ext: 'jar' }, - id: 'g:a:jar:1', - }, - ], - }, - paths, - } -} - -describe('compute-artifacts sidecar', () => { - it('emits the frozen ResolvedComponent[] contract', () => { - const facts: SocketFactsSbom = { - components: [ - { - type: 'maven', - namespace: 'com.example', - name: 'lib', - version: 'da517db', - qualifiers: { ext: 'jar' }, - id: 'com.example:lib:jar:da517db', - }, - ], - } - const artifactPaths = emptyArtifactPaths() - artifactPaths.targetsByCoord.set('com.example:lib:jar:da517db', [ - '/abs/lib.jar', - ]) - artifactPaths.sourcesByCoord.set('com.example:lib:jar:da517db', [ - '/abs/lib/src/main/java', - ]) - - const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, artifactPaths) - const resolved = serializeSidecar(acc) - - expect(resolved).toEqual([ - { - group: 'com.example', - name: 'lib', - version: 'da517db', - ext: 'jar', - classifier: null, - ecosystem: 'maven', - targets: ['/abs/lib.jar'], - sources: ['/abs/lib/src/main/java'], - }, - ]) - }) - - it('emits empty target/source arrays for a resolved-but-artifactless coord (pom/BOM)', () => { - const facts: SocketFactsSbom = { - components: [ - { - type: 'maven', - namespace: 'com.example', - name: 'bom', - version: '1.0', - qualifiers: { ext: 'pom' }, - id: 'com.example:bom:pom:1.0', - }, - ], - } - const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, emptyArtifactPaths()) - const resolved = serializeSidecar(acc) - - expect(resolved).toHaveLength(1) - expect(resolved[0]!.targets).toEqual([]) - expect(resolved[0]!.sources).toEqual([]) - }) - - it('preserves a classifier qualifier and defaults it to null when absent', () => { - const facts: SocketFactsSbom = { - components: [ - { - type: 'maven', - namespace: 'g', - name: 'a', - version: '1', - qualifiers: { ext: 'jar', classifier: 'sources' }, - id: 'g:a:jar:sources:1', - }, - ], - } - const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, emptyArtifactPaths()) - expect(serializeSidecar(acc)[0]!.classifier).toBe('sources') - }) - - it('carries a first-party module (project, not a component) source/target roots', () => { - const facts: SocketFactsSbom = { - // The app module is a project but nothing depends on it, so it is absent - // from components — its source roots must still reach the sidecar. - components: [], - projects: [ - { - type: 'maven', - namespace: 'com.example', - name: 'app', - version: '1.0', - subprojectDir: 'app', - dependencies: [], - resolvedAs: [], - }, - ], - } - const artifactPaths = emptyArtifactPaths() - artifactPaths.sourcesByCoord.set('com.example:app:1.0', [ - '/abs/app/src/main/java', - ]) - artifactPaths.targetsByCoord.set('com.example:app:1.0', [ - '/abs/app/build/classes', - ]) - - const acc: SidecarAccumulator = new Map() - accumulateSidecar(acc, facts, artifactPaths) - const resolved = serializeSidecar(acc) - - expect(resolved).toEqual([ - { - group: 'com.example', - name: 'app', - version: '1.0', - ext: '', - classifier: null, - ecosystem: 'maven', - targets: ['/abs/app/build/classes'], - sources: ['/abs/app/src/main/java'], - }, - ]) - }) - - it('merges the same coordinate across build roots, unioning paths', () => { - const acc: SidecarAccumulator = new Map() - const a = mkRootFixture('/root-a/a.jar') - const b = mkRootFixture('/root-b/a.jar') - accumulateSidecar(acc, a.facts, a.paths) - accumulateSidecar(acc, b.facts, b.paths) - const resolved = serializeSidecar(acc) - - expect(resolved).toHaveLength(1) - expect(resolved[0]!.targets).toEqual(['/root-a/a.jar', '/root-b/a.jar']) - }) -}) - // The dotnet emitter's records for one project that resolved two target -// frameworks. NuGet coordinates are groupless, so the `group` field is empty -// throughout — that is what makes the namespace and accumulator-key handling -// load-bearing rather than cosmetic. +// frameworks. NuGet coordinates are groupless, so the namespace is empty +// throughout — that is what makes the purl type load-bearing rather than +// decoration. const DOTNET_RECORDS = [ 'meta\tdotnet\t8.0.404\t', 'project\t/repo/App/App.csproj\t\tApp\t1.0.0\tApp', @@ -200,32 +32,83 @@ const DOTNET_RECORDS = [ 'scanned\tnet6.0', ].join('\n') -describe('sidecar ecosystem tagging', () => { +// A Maven build whose artifactId collides with the NuGet id above. +const MAVEN_RECORDS = [ + 'meta\tmaven\t3.9.6\t17', + 'root\tr1\t:app\tcompile\t1', + 'node\tr1\tNewtonsoft.Json:13.0.3\t\tNewtonsoft.Json\t13.0.3\t\t\t1', +].join('\n') + +const DOTNET_FACTS_FILE = '/repo/App/.socket.facts.json' + +const MAVEN_FACTS_FILE = '/repo/.socket.facts.json' + +describe('sidecar purl types across ecosystems', () => { it('keeps a nuget coordinate separate from a maven one of the same name', () => { const acc = createSidecarAccumulator() const dotnet = assembleFacts(parseRecords(DOTNET_RECORDS), { fileExists: () => true, }) - accumulateSidecar(acc, dotnet.facts, dotnet.artifactPaths) + accumulateSidecar( + acc, + dotnet.facts, + dotnet.artifactPaths, + DOTNET_FACTS_FILE, + ) + const maven = assembleFacts(parseRecords(MAVEN_RECORDS), { + fileExists: () => true, + }) + accumulateSidecar(acc, maven.facts, maven.artifactPaths, MAVEN_FACTS_FILE) + const resolved = serializeSidecar(acc) + + // Two guarantees, not one: the facts-file key scopes each reactor's + // entries, and the purl type discriminates within a bucket. + const dotnetEntry = resolved[DOTNET_FACTS_FILE]!.components.find( + c => c.name === 'Newtonsoft.Json', + ) + const mavenEntry = resolved[MAVEN_FACTS_FILE]!.components.find( + c => c.name === 'Newtonsoft.Json', + ) + + expect(dotnetEntry?.type).toBe('nuget') + expect(mavenEntry?.type).toBe('maven') + }) + + it('carries the dotnet runtime assembly onto the nuget component', () => { + const acc = createSidecarAccumulator() + const dotnet = assembleFacts(parseRecords(DOTNET_RECORDS), { + fileExists: () => true, + }) + accumulateSidecar( + acc, + dotnet.facts, + dotnet.artifactPaths, + DOTNET_FACTS_FILE, + ) + + const entry = serializeSidecar(acc)[DOTNET_FACTS_FILE]!.components.find( + c => c.name === 'Newtonsoft.Json', + ) + expect(entry?.targets).toEqual([ + '/cache/newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll', + ]) + }) - // A groupless NuGet id and a Maven artifactId can produce the same - // coordinate key; only the ecosystem tag keeps them apart. - const maven = assembleFacts( - parseRecords( - [ - 'meta\tmaven\t3.9.6\t17', - 'root\tr1\t:app\tcompile\t1', - 'node\tr1\tNewtonsoft.Json:13.0.3\t\tNewtonsoft.Json\t13.0.3\t\t\t1', - ].join('\n'), - ), - { fileExists: () => true }, + it('carries the first-party dotnet project source and output roots', () => { + const acc = createSidecarAccumulator() + const dotnet = assembleFacts(parseRecords(DOTNET_RECORDS), { + fileExists: () => true, + }) + accumulateSidecar( + acc, + dotnet.facts, + dotnet.artifactPaths, + DOTNET_FACTS_FILE, ) - accumulateSidecar(acc, maven.facts, maven.artifactPaths) - const ecosystems = serializeSidecar(acc) - .filter(e => e.name === 'Newtonsoft.Json') - .map(e => e.ecosystem) - .toSorted() - expect(ecosystems).toStrictEqual(['maven', 'nuget']) + const project = serializeSidecar(acc)[DOTNET_FACTS_FILE]!.projects[0]! + expect(project.type).toBe('nuget') + expect(project.sources).toEqual(['/repo/App']) + expect(project.targets).toEqual(['/repo/App/bin/App.dll']) }) }) diff --git a/test/repo/unit/validate-sbom.test.mts b/test/repo/unit/validate-sbom.test.mts index 3535793a..1c8b09f3 100644 --- a/test/repo/unit/validate-sbom.test.mts +++ b/test/repo/unit/validate-sbom.test.mts @@ -14,14 +14,18 @@ const RECORDS = [ 'node\tr1\torg.example:lib:jar:2.0.0\torg.example\tlib\t2.0.0\tjar\t\t1', ].join('\n') +function minimalMetadata(): Record { + return { + format: 'socket-facts-sbom', + tool: 'gradle', + toolVersion: '8.14', + } +} + function minimalSbom(): Record { return { components: [{ id: 'org.example:lib:2.0.0', name: 'lib', type: 'maven' }], - metadata: { - format: 'socket-facts-sbom', - tool: 'gradle', - toolVersion: '8.14', - }, + metadata: minimalMetadata(), } } @@ -38,7 +42,7 @@ describe('validateSocketFactsSbom', () => { it('rejects a metadata format other than socket-facts-sbom', () => { const sbom = minimalSbom() - sbom['metadata'] = { ...minimalSbom()['metadata'], format: 'cyclonedx' } + sbom['metadata'] = { ...minimalMetadata(), format: 'cyclonedx' } const result = validateSocketFactsSbom(sbom) expect(result.ok).toBe(false) expect(result.ok === false && result.violations[0]?.path).toBe( @@ -48,7 +52,7 @@ describe('validateSocketFactsSbom', () => { it('rejects a tool outside gradle, maven, and sbt', () => { const sbom = minimalSbom() - sbom['metadata'] = { ...minimalSbom()['metadata'], tool: 'bazel' } + sbom['metadata'] = { ...minimalMetadata(), tool: 'bazel' } const result = validateSocketFactsSbom(sbom) expect(result.ok).toBe(false) expect(result.ok === false && result.violations[0]?.path).toBe( diff --git a/test/repo/unit/validate-sidecar.test.mts b/test/repo/unit/validate-sidecar.test.mts index 7fb2ed29..8b927a22 100644 --- a/test/repo/unit/validate-sidecar.test.mts +++ b/test/repo/unit/validate-sidecar.test.mts @@ -2,96 +2,184 @@ import { describe, expect, it } from 'vitest' import { assertResolvedPathsSidecar, - RESOLVED_COMPONENT_FIELDS, + SIDECAR_COMPONENT_FIELDS, + SIDECAR_PROJECT_FIELDS, validateResolvedPathsSidecar, } from '../../../src/contract/validate-sidecar.mts' -import type { ResolvedComponent } from '../../../src/contract/sidecar.mts' +const FACTS_FILE = '/repo/.socket.facts.json' +// Every field the strict consumer lists, so the field-list tests below compare +// against a complete entry rather than a partial one. function component( - overrides: Partial = {}, + overrides: Record = {}, ): Record { return { - classifier: null, - ecosystem: 'maven', - ext: 'jar', - group: 'org.example', + dependencies: ['org.example:other:jar:1.0.0'], + dev: false, + direct: true, + id: 'org.example:lib:jar:1.2.3', name: 'lib', + namespace: 'org.example', + qualifiers: { ext: 'jar' }, sources: [], targets: ['/repo/lib.jar'], + type: 'maven', version: '1.2.3', ...overrides, } } +function project( + overrides: Record = {}, +): Record { + return { + dependencies: ['org.example:lib:jar:1.2.3'], + name: 'app', + namespace: 'org.example', + qualifiers: {}, + resolvedAs: [], + sources: ['/repo/app/src/main/java'], + subprojectDir: 'app', + targets: ['/repo/app/target/classes'], + type: 'maven', + version: '1.0.0', + ...overrides, + } +} + +function sidecar( + overrides: Record = {}, +): Record { + return { + [FACTS_FILE]: { + components: [component()], + projects: [project()], + ...overrides, + }, + } +} + describe('validateResolvedPathsSidecar', () => { - it('accepts a bare array of well-formed components', () => { - const result = validateResolvedPathsSidecar([component()]) - expect(result.ok).toBe(true) + it('accepts a well-formed sidecar keyed by facts file', () => { + expect(validateResolvedPathsSidecar(sidecar()).ok).toBe(true) }) it('accepts the empty sidecar', () => { - expect(validateResolvedPathsSidecar([]).ok).toBe(true) + expect(validateResolvedPathsSidecar({}).ok).toBe(true) + }) + + it('accepts an entry with targets and sources absent, the no-coordinate case', () => { + const degenerate = component() + delete degenerate['targets'] + delete degenerate['sources'] + + expect( + validateResolvedPathsSidecar({ + [FACTS_FILE]: { components: [degenerate], projects: [] }, + }).ok, + ).toBe(true) + }) + + it('rejects a bare array, the shape the keyed record replaced', () => { + const result = validateResolvedPathsSidecar([component()]) + + expect(result.ok).toBe(false) + expect(result.ok ? '' : result.violations[0]?.path).toBe('(root)') }) - it('rejects an envelope object around the array', () => { + it('rejects a reactor entry whose components is not an array', () => { const result = validateResolvedPathsSidecar({ - components: [component()], + [FACTS_FILE]: { components: {}, projects: [] }, }) + expect(result.ok).toBe(false) - expect(result.ok === false && result.violations[0]?.path).toBe('(root)') + expect(result.ok ? '' : result.violations[0]?.path).toBe( + `${FACTS_FILE}.components`, + ) }) - it('rejects an omitted classifier, because the wire form is an explicit null', () => { - const entry = component() - delete entry['classifier'] - const result = validateResolvedPathsSidecar([entry]) + it('rejects an unknown component field the way the strict consumer would', () => { + const result = validateResolvedPathsSidecar({ + [FACTS_FILE]: { + components: [component({ ecosystem: 'maven' })], + projects: [], + }, + }) + expect(result.ok).toBe(false) - expect(result.ok === false && result.violations[0]?.path).toBe( - '[0].classifier', + expect(result.ok ? '' : result.violations[0]?.path).toBe( + `${FACTS_FILE}.components[0].ecosystem`, ) - expect(result.ok === false && result.violations[0]?.message).toContain( - 'explicit JSON null', + }) + + it('rejects an unknown project field the way the strict consumer would', () => { + const result = validateResolvedPathsSidecar({ + [FACTS_FILE]: { + components: [], + projects: [project({ id: 'org.example:app:1.0.0' })], + }, + }) + + expect(result.ok).toBe(false) + expect(result.ok ? '' : result.violations[0]?.path).toBe( + `${FACTS_FILE}.projects[0].id`, ) }) - it('accepts a string classifier', () => { - expect( - validateResolvedPathsSidecar([component({ classifier: 'sources' })]).ok, - ).toBe(true) + it('rejects an unknown reactor-entry field', () => { + const result = validateResolvedPathsSidecar({ + [FACTS_FILE]: { components: [], projects: [], metadata: {} }, + }) + + expect(result.ok).toBe(false) + expect(result.ok ? '' : result.violations[0]?.path).toBe( + `${FACTS_FILE}.metadata`, + ) }) - // The consumer parses this payload with a strict schema, so an additive field - // is a hard parse failure on its side. Catching it here means the producer - // fails before it writes a sidecar the consumer will reject wholesale. - it('rejects an unknown field the way the strict consumer would', () => { - const result = validateResolvedPathsSidecar([ - component({}) && { ...component(), schemaVersion: 2 }, - ]) + it('rejects a component missing its id', () => { + const headless = component() + delete headless['id'] + + const result = validateResolvedPathsSidecar({ + [FACTS_FILE]: { components: [headless], projects: [] }, + }) + expect(result.ok).toBe(false) - expect(result.ok === false && result.violations[0]?.path).toBe( - '[0].schemaVersion', + expect(result.ok ? '' : result.violations[0]?.path).toBe( + `${FACTS_FILE}.components[0].id`, ) }) it('reports every malformed field rather than the first', () => { - const result = validateResolvedPathsSidecar([ - { classifier: null, ext: 1, group: 2, name: 3, sources: 4, targets: 5 }, - ]) + const result = validateResolvedPathsSidecar({ + [FACTS_FILE]: { + components: [component({ name: 7, targets: 'not an array' })], + projects: [], + }, + }) + expect(result.ok).toBe(false) - expect(result.ok === false && result.violations.length).toBeGreaterThan(4) + expect(result.ok ? 0 : result.violations.length).toBeGreaterThan(1) }) - it('keeps its field list in sync with the serialized shape', () => { - expect([...RESOLVED_COMPONENT_FIELDS]).toEqual( + it('keeps the component field list in sync with the serialized shape', () => { + expect([...SIDECAR_COMPONENT_FIELDS]).toEqual( Object.keys(component()).toSorted(), ) }) + + it('keeps the project field list in sync with the serialized shape', () => { + expect([...SIDECAR_PROJECT_FIELDS]).toEqual( + Object.keys(project()).toSorted(), + ) + }) }) describe('assertResolvedPathsSidecar', () => { it('returns the payload when it conforms', () => { - const payload = [component()] + const payload = sidecar() expect(assertResolvedPathsSidecar(payload, 'a test')).toBe(payload) }) @@ -108,21 +196,3 @@ describe('assertResolvedPathsSidecar', () => { expect(message).toContain('Fix:') }) }) - -describe('the ecosystem tag', () => { - it('still accepts a sidecar written before the tag existed', () => { - const legacy = component() - delete legacy['ecosystem'] - - expect(validateResolvedPathsSidecar([legacy]).ok).toBe(true) - }) - - it('rejects a non-string tag', () => { - const result = validateResolvedPathsSidecar([ - component({ ecosystem: 7 } as never), - ]) - - expect(result.ok).toBe(false) - expect(result.ok ? [] : result.violations[0]?.path).toBe('[0].ecosystem') - }) -})