Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Fixed
- Socket facts for a Maven or Gradle build pointed elsewhere with `-f` or `-p` are now written into that build's own directory, where their paths resolve.

## [1.6.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.6.1) - 2026-10-08

### Changed
Expand Down
12 changes: 9 additions & 3 deletions src/commands/manifest/generate-recursive-manifests.mts
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,13 @@ async function runEcosystemCandidates({
}

covered.add(dir)
// `-f`/`-p` can root the reactor away from `dir`.
// eslint-disable-next-line no-await-in-loop
const buildRoot = await realpathOrResolved(path.dirname(result.factsPath))
// eslint-disable-next-line no-await-in-loop
const resolvedSubprojectDirs = await Promise.all(
result.projects.map(project =>
realpathOrResolved(path.resolve(dir, project.subprojectDir)),
realpathOrResolved(path.resolve(buildRoot, project.subprojectDir)),
),
)
for (const subprojectDir of resolvedSubprojectDirs) {
Expand All @@ -203,7 +206,10 @@ async function runEcosystemCandidates({
// meaningful data point, not a redundant one. Never suppress its own
// build-root invocation, regardless of which reactor(s) also
// incorporate it or the order candidates happen to be discovered in.
if (subprojectDir.startsWith(`${dir}${path.sep}`)) {
if (
subprojectDir === buildRoot ||
subprojectDir.startsWith(`${buildRoot}${path.sep}`)
) {
covered.add(subprojectDir)
}
}
Expand All @@ -218,7 +224,7 @@ async function runEcosystemCandidates({
return outcomes
}

// Generates one .socket.facts.json per independent gradle/sbt/maven build
// Generates one Socket facts file per independent gradle/sbt/maven build
// root under `cwd`. Coverage is tracked per ecosystem via the facts SBOM's
// own projects[].subprojectDir, not by pruning the whole discovered subtree,
// so an unrelated nested project a reactor doesn't declare still gets its
Expand Down
53 changes: 53 additions & 0 deletions src/commands/manifest/generate-recursive-manifests.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,59 @@ describe('generateRecursiveManifests', () => {
},
)

it('judges coverage against the reported build root, not the discovery directory', async () => {
const outer = await fs.realpath(
await fs.mkdtemp(path.join(tmpdir(), 'relocated-build-root-')),
)
const buildRoot = path.join(outer, 'build')
const member = path.join(buildRoot, 'member')
const escaped = path.join(outer, 'escaped')
try {
for (const dir of [outer, member, escaped]) {
// eslint-disable-next-line no-await-in-loop
await fs.mkdir(dir, { recursive: true })
// eslint-disable-next-line no-await-in-loop
await fs.writeFile(path.join(dir, 'pom.xml'), '<project/>')
}
vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => {
if (cwd === outer) {
return {
factsPath: path.join(buildRoot, 'x.xml.socket.facts.json'),
projects: [
{
type: 'maven',
name: 'member',
subprojectDir: 'member',
dependencies: [],
},
{
type: 'maven',
name: 'escaped',
subprojectDir: '../escaped',
dependencies: [],
},
],
}
}
return {
factsPath: path.join(cwd, 'pom.xml.socket.facts.json'),
projects: [],
}
})

const outcomes = await generateRecursiveManifests({
cwd: outer,
verbose: false,
})

const byDir = new Map(outcomes.map(o => [o.dir, o.status]))
expect(byDir.get(member)).toBe('skippedCovered')
expect(byDir.get(escaped)).toBe('generated')
} finally {
await fs.rm(outer, { recursive: true, force: true })
}
})

it("runs both ecosystems unconditionally at a dual-marker directory (matches auto's existing behavior)", async () => {
vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({
factsPath: path.join(cwd, '.socket.facts.json'),
Expand Down
16 changes: 13 additions & 3 deletions src/commands/manifest/run-manifest-facts.mts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,6 @@ export async function runManifestFacts({
verbose: boolean
withFiles?: boolean | undefined
}): Promise<RunManifestFactsOutcome> {
const factsPath = path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON)

let resolvedJavaHome: string | undefined
if (javaHome) {
const expanded = expandEnvVarRefs(javaHome)
Expand Down Expand Up @@ -160,7 +158,8 @@ export async function runManifestFacts({
)
return null
}
const { artifactPaths, code, facts, report, stderr, stdout } = result
const { artifactPaths, buildRoot, code, facts, report, stderr, stdout } =
result

const rendered = renderResolutionErrorReport(
report.failures,
Expand Down Expand Up @@ -230,6 +229,17 @@ export async function runManifestFacts({
return
}

if (!buildRoot) {
process.exitCode = 1
logger.fail(
`The ${ecosystem} build did not report its root directory, so its Socket facts file cannot be placed.`,
)
return null
}
// Every path in the facts is relative to the build root, which `-f`/`-p`
// can move away from cwd.
const factsPath = path.join(buildRoot, constants.DOT_SOCKET_DOT_FACTS_JSON)

const socketCliVersion = constants.ENV.INLINED_SOCKET_CLI_VERSION
if (facts.metadata && socketCliVersion) {
facts.metadata.socketCliVersion = socketCliVersion
Expand Down
55 changes: 47 additions & 8 deletions src/commands/manifest/run-manifest-facts.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import type { SidecarAccumulator } from './scripts/sidecar.mts'

const ENV_VAR = 'SOCKET_TEST_JAVA_HOME'

function okResult(): ManifestRunResult {
function okResult(buildRoot: string): ManifestRunResult {
return {
buildRoot,
code: 0,
facts: {
components: [{ id: 'a', type: 'maven', name: 'a' }],
Expand Down Expand Up @@ -63,15 +64,15 @@ describe('runManifestFacts - javaHome', () => {
})

it('passes a literal javaHome straight through as JAVA_HOME', async () => {
vi.mocked(runManifestScript).mockResolvedValue(okResult())
vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd))
await runManifestFacts({ ...baseArgs, cwd, javaHome: '/opt/jdk-17' })
const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1]
expect(opts?.env?.['JAVA_HOME']).toBe('/opt/jdk-17')
})

it('expands $VAR and ${VAR} references against the CLI process env', async () => {
process.env[ENV_VAR] = '/opt/jdk-11'
vi.mocked(runManifestScript).mockResolvedValue(okResult())
vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd))
await runManifestFacts({
...baseArgs,
cwd,
Expand All @@ -82,7 +83,7 @@ describe('runManifestFacts - javaHome', () => {
})

it('fails closed without invoking the build tool when the referenced var is unset', async () => {
vi.mocked(runManifestScript).mockResolvedValue(okResult())
vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd))
const result = await runManifestFacts({
...baseArgs,
cwd,
Expand All @@ -94,7 +95,7 @@ describe('runManifestFacts - javaHome', () => {
})

it('leaves the environment untouched when javaHome is unset', async () => {
vi.mocked(runManifestScript).mockResolvedValue(okResult())
vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd))
await runManifestFacts({ ...baseArgs, cwd })
const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1]
expect(opts?.env).toBeUndefined()
Expand All @@ -115,7 +116,7 @@ describe('runManifestFacts - sidecar', () => {
})

it('keys the sidecar by the symlink-resolved factsPath, not the raw cwd-joined one', async () => {
const result = okResult()
const result = okResult(cwd)
result.facts.projects = [
{
type: 'maven',
Expand All @@ -139,7 +140,7 @@ describe('runManifestFacts - sidecar', () => {
expect(bucket?.projects.find(m => m.name === 'app')).toBeDefined()
})
it('stamps the inlined socket-cli version into the written facts metadata', async () => {
const result = okResult()
const result = okResult(cwd)
result.facts.metadata = {
format: 'socket-facts-sbom',
tool: 'maven',
Expand All @@ -160,6 +161,44 @@ describe('runManifestFacts - sidecar', () => {
})
})

describe('runManifestFacts - build root', () => {
let cwd = ''

beforeEach(async () => {
cwd = await fs.mkdtemp(path.join(tmpdir(), 'run-manifest-facts-'))
vi.mocked(runManifestScript).mockReset()
process.exitCode = undefined
})
afterEach(async () => {
await fs.rm(cwd, { recursive: true, force: true })
process.exitCode = undefined
})

it('writes the facts file into the build root the tool reports', async () => {
const buildRoot = path.join(cwd, 'sub')
await fs.mkdir(buildRoot)
vi.mocked(runManifestScript).mockResolvedValue(okResult(buildRoot))

const outcome = await runManifestFacts({ ...baseArgs, cwd })

expect(outcome?.factsPath).toBe(path.join(buildRoot, '.socket.facts.json'))
expect(await fs.readdir(buildRoot)).toEqual(['.socket.facts.json'])
})

it('fails without writing when the build did not report its root', async () => {
vi.mocked(runManifestScript).mockResolvedValue({
...okResult(cwd),
buildRoot: undefined,
})

const outcome = await runManifestFacts({ ...baseArgs, cwd })

expect(outcome).toBeNull()
expect(process.exitCode).toBe(1)
expect(await fs.readdir(cwd)).toEqual([])
})
})

describe('runManifestFacts - sbt build detection', () => {
let cwd = ''

Expand Down Expand Up @@ -196,7 +235,7 @@ describe('runManifestFacts - sbt build detection', () => {
} else {
await fs.writeFile(path.join(cwd, marker), '')
}
vi.mocked(runManifestScript).mockResolvedValue(okResult())
vi.mocked(runManifestScript).mockResolvedValue(okResult(cwd))

await runManifestFacts({ ...baseArgs, cwd, ecosystem: 'sbt' })

Expand Down
10 changes: 10 additions & 0 deletions src/commands/manifest/scripts/assemble.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,13 @@ describe('records → assemble → sidecar', () => {
})
})
})

describe('parseRecords', () => {
it('reads the build root the build reports', () => {
expect(
parseRecords(
['meta\tmaven\t3.9.6\t17', 'buildRoot\t/repo/sub'].join('\n'),
).buildRoot,
).toBe('/repo/sub')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public void run(MavenSession session, List<MavenProject> reactor, File rootDir,

List<String> lines = new ArrayList<>();
rec(lines, "meta", "maven", mavenVersion, System.getProperty("java.version"));
rec(lines, "buildRoot", rootDir.getAbsolutePath());
Comment thread
jfblaa marked this conversation as resolved.

for (MavenProject module : reactor) {
// No basedir: Maven's stand-in project for a directory without a POM. Skipping it lets Maven's
Expand Down
7 changes: 7 additions & 0 deletions src/commands/manifest/scripts/records.mts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
// <tag>\t<field>\t<field>...
//
// meta tool toolVersion javaVersion
// buildRoot path (absolute; the facts file's directory)
// project projectKey group name version dir
// projectSrc projectKey path (--with-files only)
// projectTgt projectKey path (--with-files only)
Expand Down Expand Up @@ -67,6 +68,8 @@ export type ParsedRecords = {
tool: string
toolVersion: string
javaVersion: string
// Absolute directory the build is rooted at; the facts file is written there.
buildRoot: string
projects: Map<string, RawProject>
roots: Map<string, RawRoot>
scannedConfigs: string[]
Expand Down Expand Up @@ -100,6 +103,7 @@ export function parseRecords(text: string): ParsedRecords {
tool: '',
toolVersion: '',
javaVersion: '',
buildRoot: '',
projects: new Map(),
roots: new Map(),
scannedConfigs: [],
Expand Down Expand Up @@ -152,6 +156,9 @@ export function parseRecords(text: string): ParsedRecords {
result.toolVersion = f[2] ?? ''
result.javaVersion = f[3] ?? ''
break
case 'buildRoot':
result.buildRoot = f[1] ?? ''
break
case 'project': {
const p = project(f[1] ?? '')
p.group = f[2] ?? ''
Expand Down
6 changes: 5 additions & 1 deletion src/commands/manifest/scripts/run.mts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export type ManifestScriptOptions = {
export type ManifestRunResult = {
code: number
facts: SocketFactsSbom
// Undefined when the build did not report it.
buildRoot: string | undefined
report: ResolutionReport
artifactPaths: ResolvedArtifactPaths
// Captured build-tool output (empty when stdio is 'inherit').
Expand Down Expand Up @@ -139,8 +141,10 @@ async function assembleFromRecords(
const text = existsSync(recordsFile)
? await fs.readFile(recordsFile, 'utf8')
: ''
const { artifactPaths, facts, report } = assembleFacts(parseRecords(text))
const parsed = parseRecords(text)
const { artifactPaths, facts, report } = assembleFacts(parsed)
return {
buildRoot: parsed.buildRoot || undefined,
code: out.code,
facts,
report,
Expand Down
2 changes: 2 additions & 0 deletions src/commands/manifest/scripts/socket-facts.init.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ rootProject { rp ->
// task actions. The Socket CLI disables the cache for this run, but hoisting is cheap insurance.
def recordsFileOverride = gradle.socketProp.call(rp, 'socket.recordsFile')?.toString()
def defaultRecordsFile = new File(rp.projectDir, '.socket.facts.records.tsv').absolutePath
def buildRootPath = rp.projectDir.absolutePath
// `sources`/`targets` are --with-files-only; a plain run emits only the graph fields.
def withFilesProjects = gradle.socketProp.call(rp, 'socket.withFiles')?.toString()?.toLowerCase() == 'true'

Expand All @@ -493,6 +494,7 @@ rootProject { rp ->
def rec = { List fields -> lines << fields.collect { esc(it) }.join('\t') }

rec(['meta', 'gradle', gradle.gradleVersion, System.getProperty('java.version')])
rec(['buildRoot', buildRootPath])

// One `project` record per build module (sources/targets only with --with-files).
def projectsInfo
Expand Down
1 change: 1 addition & 0 deletions src/commands/manifest/scripts/socket-facts.plugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ object SocketFactsPlugin extends AutoPlugin {
}

rec("meta", "sbt", extracted.getOpt(sbtVersion).getOrElse(""), sys.props.getOrElse("java.version", ""))
rec("buildRoot", rootCanonPath.toString)

// One `project` record per build module (sources/targets only with --with-files). Excluded
// subprojects are omitted (they were also skipped during resolution above).
Expand Down