diff --git a/packages/nx/src/command-line/show/show-target/inputs.ts b/packages/nx/src/command-line/show/show-target/inputs.ts index 744d308232e5b..9ba6af3f66ac8 100644 --- a/packages/nx/src/command-line/show/show-target/inputs.ts +++ b/packages/nx/src/command-line/show/show-target/inputs.ts @@ -1,7 +1,6 @@ import type { TargetConfiguration } from '../../../config/workspace-json-project-json'; import type { HashInputs } from '../../../native'; -import { workspaceRoot } from '../../../utils/workspace-root'; -import { handleImport } from '../../../utils/handle-import'; +import { createTaskFileResolver } from '../../../hasher/task-file-resolver'; import type { ShowTargetInputsOptions } from '../command-object'; import { resolveTarget, @@ -10,7 +9,6 @@ import { hasCustomHasher, pc, printList, - type ResolvedTarget, } from './utils'; // ── Handler ───────────────────────────────────────────────────────── @@ -32,16 +30,29 @@ export async function showTargetInputsHandler( return; } - const hashInputs = await resolveInputFiles(t); + const { projectName, targetName, configuration } = t; + const taskId = configuration + ? `${projectName}:${targetName}:${configuration}` + : `${projectName}:${targetName}`; + + const resolver = await createTaskFileResolver({ + projectGraph: t.graph, + nxJson: t.nxJson, + }); + + const hashInputs = resolver.getRawInputs(taskId); + if (!hashInputs) { + throw new Error(`Could not find hash plan for task "${taskId}".`); + } if (args.check !== undefined) { const checkItems = deduplicateFolderEntries(args.check); const results = checkItems.map((input) => - resolveCheckFromInputs(input, t.projectName, t.targetName, hashInputs) + resolveCheckFromInputs(input, projectName, targetName, hashInputs) ); if (results.length >= 2) { - renderBatchCheckInputs(results, t.projectName, t.targetName); + renderBatchCheckInputs(results, projectName, targetName); } else { for (const data of results) renderCheckInput(data); } @@ -54,8 +65,8 @@ export async function showTargetInputsHandler( } renderInputs( - { project: t.projectName, target: t.targetName, ...hashInputs }, - t.node.data.targets[t.targetName].inputs, + { project: projectName, target: targetName, ...hashInputs }, + t.node.data.targets[targetName].inputs, args ); } @@ -64,36 +75,6 @@ export async function showTargetInputsHandler( type CheckInputResult = ReturnType; -async function resolveInputFiles(t: ResolvedTarget): Promise { - const { projectName, targetName, configuration, graph, nxJson } = t; - const { HashPlanInspector } = (await handleImport( - '../../../hasher/hash-plan-inspector.js', - __dirname - )) as typeof import('../../../hasher/hash-plan-inspector'); - - const inspector = new HashPlanInspector(graph, workspaceRoot, nxJson); - await inspector.init(); - - const plan = inspector.inspectTaskInputs({ - project: projectName, - target: targetName, - configuration, - }); - - const targetConfig = graph.nodes[projectName]?.data?.targets?.[targetName]; - const effectiveConfig = configuration ?? targetConfig?.defaultConfiguration; - const taskId = effectiveConfig - ? `${projectName}:${targetName}:${effectiveConfig}` - : `${projectName}:${targetName}`; - const result = plan[taskId]; - if (!result) { - throw new Error( - `Could not find hash plan for task "${taskId}". Available tasks: ${Object.keys(plan).join(', ')}` - ); - } - return result; -} - function resolveCheckFromInputs( rawValue: string, projectName: string, diff --git a/packages/nx/src/command-line/show/show-target/outputs.ts b/packages/nx/src/command-line/show/show-target/outputs.ts index 2415b0d045e3d..0b3f34d47a551 100644 --- a/packages/nx/src/command-line/show/show-target/outputs.ts +++ b/packages/nx/src/command-line/show/show-target/outputs.ts @@ -1,4 +1,7 @@ -import { getOutputsForTargetAndConfiguration } from '../../../tasks-runner/utils'; +import { + createTaskFileResolver, + type TaskFileResolver, +} from '../../../hasher/task-file-resolver'; import { workspaceRoot } from '../../../utils/workspace-root'; import type { ShowTargetOutputsOptions } from '../command-object'; import { @@ -16,12 +19,22 @@ export async function showTargetOutputsHandler( args: ShowTargetOutputsOptions ): Promise { const t = await resolveTarget(args); - const outputsData = resolveOutputsData(t); + + const taskId = t.configuration + ? `${t.projectName}:${t.targetName}:${t.configuration}` + : `${t.projectName}:${t.targetName}`; + + const resolver = await createTaskFileResolver({ + projectGraph: t.graph, + nxJson: t.nxJson, + }); + + const outputsData = resolveOutputsData(t, resolver, taskId); if (args.check !== undefined) { const checkItems = deduplicateFolderEntries(args.check); const results = checkItems.map((o) => - resolveCheckOutputData(o, outputsData) + resolveCheckOutputData(o, outputsData, resolver, taskId) ); if (results.length >= 2) { @@ -49,13 +62,15 @@ export async function showTargetOutputsHandler( type OutputsData = ReturnType; type CheckOutputResult = ReturnType; -function resolveOutputsData(t: ResolvedTarget) { +function resolveOutputsData( + t: ResolvedTarget, + resolver: TaskFileResolver, + taskId: string +) { const { projectName, targetName, configuration, node } = t; - const resolvedOutputs = getOutputsForTargetAndConfiguration( - { project: projectName, target: targetName, configuration }, - {}, - node - ); + // Use the resolver to obtain resolved output paths — avoids duplicating + // the getOutputsForTargetAndConfiguration call that the resolver already makes. + const resolvedOutputs = resolver.getOutputs(taskId); const targetConfig = node.data.targets?.[targetName]; const configuredOutputs: string[] = targetConfig?.outputs ?? []; @@ -93,25 +108,17 @@ function resolveOutputsData(t: ResolvedTarget) { function resolveCheckOutputData( rawFileToCheck: string, - outputsData: OutputsData + outputsData: OutputsData, + resolver: TaskFileResolver, + taskId: string ) { const fileToCheck = normalizePath(rawFileToCheck); const { outputPaths, expandedOutputs } = outputsData; - let matchedOutput: string | null = null; - for (const outputPath of outputPaths) { - const normalizedOutput = outputPath.replace(/\\/g, '/'); - if ( - fileToCheck === normalizedOutput || - fileToCheck.startsWith(normalizedOutput + '/') - ) { - matchedOutput = outputPath; - break; - } - } - if (!matchedOutput && expandedOutputs.includes(fileToCheck)) { - matchedOutput = fileToCheck; - } + // Delegate the direct-match decision to the resolver (handles exact, prefix, + // and glob matching via minimatch — supersedes the previous manual prefix + // comparison + expandedOutputs exact-match approach). + const matchedOutput = resolver.isOutput(taskId, fileToCheck); let containedOutputPaths: string[] = []; let containedExpandedOutputs: string[] = []; diff --git a/packages/nx/src/devkit-internals.ts b/packages/nx/src/devkit-internals.ts index 402b7c1963a10..1967bcc4ff1c8 100644 --- a/packages/nx/src/devkit-internals.ts +++ b/packages/nx/src/devkit-internals.ts @@ -47,3 +47,6 @@ export { globalSpinner } from './utils/spinner'; export { signalToCode } from './utils/exit-codes'; export { handleImport } from './utils/handle-import'; export { PluginCache, safeWriteFileCache } from './utils/plugin-cache-utils'; +export { HashPlanInspector } from './hasher/hash-plan-inspector'; +export type { TaskFileResolver } from './hasher/task-file-resolver'; +export { createTaskFileResolver } from './hasher/task-file-resolver'; diff --git a/packages/nx/src/hasher/task-file-resolver.spec.ts b/packages/nx/src/hasher/task-file-resolver.spec.ts new file mode 100644 index 0000000000000..3b1a6f188f70f --- /dev/null +++ b/packages/nx/src/hasher/task-file-resolver.spec.ts @@ -0,0 +1,717 @@ +import type { ProjectGraph } from '../config/project-graph'; +import type { HashInputs } from '../native'; + +// ── Mocks must be declared BEFORE imports that use them ───────────────────── +// +// jest.mock() calls are hoisted to the top of the file by Babel/SWC, which +// means any factory closure runs before module-level `const` declarations are +// initialised. The pattern below avoids referencing outer-scope variables +// inside the factory; instead we configure the mock in beforeEach so every +// test starts with a fresh set of spies. + +jest.mock('./hash-plan-inspector', () => ({ + HashPlanInspector: jest.fn(), +})); + +jest.mock('../tasks-runner/utils', () => ({ + getOutputsForTargetAndConfiguration: jest.fn(), +})); + +jest.mock('../tasks-runner/create-task-graph', () => ({ + createTaskGraph: jest.fn(), +})); + +jest.mock('./task-hasher', () => ({ + getInputs: jest.fn(), +})); + +// ── Imports (after mocks) ──────────────────────────────────────────────────── + +// eslint-disable-next-line import/order +import { HashPlanInspector } from './hash-plan-inspector'; +// eslint-disable-next-line import/order +import { getOutputsForTargetAndConfiguration } from '../tasks-runner/utils'; +// eslint-disable-next-line import/order +import { createTaskGraph } from '../tasks-runner/create-task-graph'; +// eslint-disable-next-line import/order +import { getInputs as mockedGetInputs } from './task-hasher'; +// eslint-disable-next-line import/order +import { createTaskFileResolver } from './task-file-resolver'; + +const MockHashPlanInspector = jest.mocked(HashPlanInspector); +const mockGetOutputs = jest.mocked(getOutputsForTargetAndConfiguration); +const mockCreateTaskGraph = jest.mocked(createTaskGraph); +const mockGetStructuredInputs = jest.mocked(mockedGetInputs); + +// ── Per-test mock spies ────────────────────────────────────────────────────── + +let mockInit: jest.Mock; +let mockInspectTaskInputs: jest.Mock; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function buildGraph(): ProjectGraph { + return { + nodes: { + myproj: { + name: 'myproj', + type: 'lib', + data: { + root: 'libs/myproj', + targets: { + build: { + executor: '@nx/js:tsc', + outputs: ['dist/libs/myproj'], + }, + }, + }, + }, + }, + dependencies: { myproj: [] }, + } as unknown as ProjectGraph; +} + +function makeHashInputs(files: string[]): HashInputs { + return { + files, + runtime: [], + environment: [], + depOutputs: [], + external: [], + }; +} + +/** + * Project graph used by the dependentTasksOutputFiles tests: includes a + * direct dep `dep` and a transitive chain `mid → deep`, all with a `build` + * target so the resolver's getOutputs() doesn't short-circuit. + */ +function buildGraphWithDeps(): ProjectGraph { + const buildTarget = { + executor: '@nx/js:tsc', + outputs: [] as string[], + }; + return { + nodes: { + myproj: { + name: 'myproj', + type: 'lib', + data: { root: 'libs/myproj', targets: { build: buildTarget } }, + }, + dep: { + name: 'dep', + type: 'lib', + data: { root: 'libs/dep', targets: { build: buildTarget } }, + }, + mid: { + name: 'mid', + type: 'lib', + data: { root: 'libs/mid', targets: { build: buildTarget } }, + }, + deep: { + name: 'deep', + type: 'lib', + data: { root: 'libs/deep', targets: { build: buildTarget } }, + }, + }, + dependencies: { myproj: [], dep: [], mid: [], deep: [] }, + } as unknown as ProjectGraph; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('createTaskFileResolver', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Rebuild per-test spies so callers can customise return values. + mockInit = jest.fn().mockResolvedValue(undefined); + mockInspectTaskInputs = jest.fn(); + + MockHashPlanInspector.mockImplementation( + () => + ({ + init: mockInit, + inspectTaskInputs: mockInspectTaskInputs, + }) as unknown as HashPlanInspector + ); + + // Default: no outputs + mockGetOutputs.mockReturnValue([]); + + // Defaults for the new dependentTasksOutputFiles code path: empty task + // graph and empty depsOutputs unless a test overrides them. + mockCreateTaskGraph.mockReturnValue({ + roots: [], + tasks: {}, + dependencies: {}, + continuousDependencies: {}, + }); + mockGetStructuredInputs.mockReturnValue({ + selfInputs: [], + depsInputs: [], + depsOutputs: [], + projectInputs: [], + depsFilesets: [], + } as ReturnType); + }); + + it('initialises the HashPlanInspector exactly once', async () => { + const graph = buildGraph(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs([]), + }); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + + // Trigger multiple calls to exercise init-once guarantee + resolver.getInputs('myproj:build'); + resolver.getInputs('myproj:build'); + resolver.getOutputs('myproj:build'); + + expect(MockHashPlanInspector).toHaveBeenCalledTimes(1); + expect(mockInit).toHaveBeenCalledTimes(1); + }); + + describe('getInputs', () => { + it('returns the expanded file list from the inspector', async () => { + const graph = buildGraph(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs([ + 'libs/myproj/src/index.ts', + 'libs/myproj/package.json', + ]), + }); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + const inputs = resolver.getInputs('myproj:build'); + + expect(inputs).toEqual([ + 'libs/myproj/src/index.ts', + 'libs/myproj/package.json', + ]); + }); + + it('returns empty array when inspector finds no files', async () => { + const graph = buildGraph(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs([]), + }); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + expect(resolver.getInputs('myproj:build')).toEqual([]); + }); + + it('caches result — inspectTaskInputs called only once per taskId', async () => { + const graph = buildGraph(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs(['libs/myproj/src/index.ts']), + }); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + resolver.getInputs('myproj:build'); + resolver.getInputs('myproj:build'); + resolver.getInputs('myproj:build'); + + expect(mockInspectTaskInputs).toHaveBeenCalledTimes(1); + }); + }); + + describe('getOutputs', () => { + it('returns the output patterns for the task', async () => { + const graph = buildGraph(); + mockGetOutputs.mockReturnValue([ + 'dist/libs/myproj', + 'dist/libs/myproj/**', + ]); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + const outputs = resolver.getOutputs('myproj:build'); + + expect(outputs).toEqual(['dist/libs/myproj', 'dist/libs/myproj/**']); + }); + + it('returns empty array when node has no matching target', async () => { + const graph = buildGraph(); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + // 'myproj:test' target does not exist in graph + const outputs = resolver.getOutputs('myproj:test'); + + expect(outputs).toEqual([]); + expect(mockGetOutputs).not.toHaveBeenCalled(); + }); + + it('caches result — getOutputsForTargetAndConfiguration called only once per taskId', async () => { + const graph = buildGraph(); + mockGetOutputs.mockReturnValue(['dist/libs/myproj']); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + resolver.getOutputs('myproj:build'); + resolver.getOutputs('myproj:build'); + resolver.getOutputs('myproj:build'); + + expect(mockGetOutputs).toHaveBeenCalledTimes(1); + }); + }); + + describe('isInput', () => { + it('returns true for a path in the input file list (exact match)', async () => { + const graph = buildGraph(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs(['libs/myproj/src/index.ts']), + }); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + expect(resolver.isInput('myproj:build', 'libs/myproj/src/index.ts')).toBe( + true + ); + }); + + it('returns false for a path NOT in the input file list', async () => { + const graph = buildGraph(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs(['libs/myproj/src/index.ts']), + }); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + expect(resolver.isInput('myproj:build', 'libs/other/src/index.ts')).toBe( + false + ); + }); + + it('returns true when path matches a materialized depOutputs entry (upstream has run)', async () => { + const graph = buildGraph(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': { + files: [], + runtime: [], + environment: [], + depOutputs: ['libs/dep/dist/index.d.ts'], + external: [], + }, + }); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + expect(resolver.isInput('myproj:build', 'libs/dep/dist/index.d.ts')).toBe( + true + ); + }); + + it('returns true when path matches a dependentTasksOutputFiles glob AND an upstream task output (upstream has NOT run)', async () => { + const graph = buildGraphWithDeps(); + // No materialized files / depOutputs — simulating "upstream not yet run". + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs([]), + }); + + // Task graph: myproj:build depends directly on dep:build. + mockCreateTaskGraph.mockReturnValue({ + roots: ['dep:build'], + tasks: { + 'myproj:build': { + id: 'myproj:build', + target: { project: 'myproj', target: 'build' }, + overrides: {}, + outputs: [], + }, + 'dep:build': { + id: 'dep:build', + target: { project: 'dep', target: 'build' }, + overrides: {}, + outputs: [], + }, + }, + dependencies: { + 'myproj:build': ['dep:build'], + 'dep:build': [], + }, + continuousDependencies: {}, + }); + + // myproj:build declares one dependentTasksOutputFiles input. + mockGetStructuredInputs.mockReturnValue({ + selfInputs: [], + depsInputs: [], + depsOutputs: [ + { dependentTasksOutputFiles: '**/*.d.ts', transitive: false }, + ], + projectInputs: [], + depsFilesets: [], + } as ReturnType); + + // dep:build declares libs/dep/dist as an output dir. + mockGetOutputs.mockImplementation(((t: { + project?: string; + target?: { project?: string }; + }) => + (t.project ?? t.target?.project) === 'dep' + ? ['libs/dep/dist'] + : []) as typeof getOutputsForTargetAndConfiguration); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + + // Path lies inside dep:build's outputs AND matches the **/*.d.ts glob. + expect(resolver.isInput('myproj:build', 'libs/dep/dist/index.d.ts')).toBe( + true + ); + }); + + it('returns false when path matches the dependentTasksOutputFiles glob but no upstream output covers it', async () => { + const graph = buildGraph(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs([]), + }); + mockCreateTaskGraph.mockReturnValue({ + roots: ['dep:build'], + tasks: { + 'myproj:build': { + id: 'myproj:build', + target: { project: 'myproj', target: 'build' }, + overrides: {}, + outputs: [], + }, + 'dep:build': { + id: 'dep:build', + target: { project: 'dep', target: 'build' }, + overrides: {}, + outputs: [], + }, + }, + dependencies: { + 'myproj:build': ['dep:build'], + 'dep:build': [], + }, + continuousDependencies: {}, + }); + mockGetStructuredInputs.mockReturnValue({ + selfInputs: [], + depsInputs: [], + depsOutputs: [ + { dependentTasksOutputFiles: '**/*.d.ts', transitive: false }, + ], + projectInputs: [], + depsFilesets: [], + } as ReturnType); + // dep:build's outputs do NOT include this path. + mockGetOutputs.mockReturnValue(['libs/dep/dist']); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + + // Matches **/*.d.ts but NOT inside libs/dep/dist. + expect( + resolver.isInput('myproj:build', 'libs/somewhere-else/index.d.ts') + ).toBe(false); + }); + + it('returns false when path lies inside an upstream output but does not match the dependentTasksOutputFiles glob', async () => { + const graph = buildGraph(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs([]), + }); + mockCreateTaskGraph.mockReturnValue({ + roots: ['dep:build'], + tasks: { + 'myproj:build': { + id: 'myproj:build', + target: { project: 'myproj', target: 'build' }, + overrides: {}, + outputs: [], + }, + 'dep:build': { + id: 'dep:build', + target: { project: 'dep', target: 'build' }, + overrides: {}, + outputs: [], + }, + }, + dependencies: { + 'myproj:build': ['dep:build'], + 'dep:build': [], + }, + continuousDependencies: {}, + }); + mockGetStructuredInputs.mockReturnValue({ + selfInputs: [], + depsInputs: [], + depsOutputs: [ + { dependentTasksOutputFiles: '**/*.d.ts', transitive: false }, + ], + projectInputs: [], + depsFilesets: [], + } as ReturnType); + mockGetOutputs.mockReturnValue(['libs/dep/dist']); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + + // .js does not match **/*.d.ts even though the path is inside the output dir. + expect(resolver.isInput('myproj:build', 'libs/dep/dist/index.js')).toBe( + false + ); + }); + + it('walks transitive task graph dependencies when transitive=true', async () => { + const graph = buildGraphWithDeps(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs([]), + }); + // myproj -> mid -> deep + mockCreateTaskGraph.mockReturnValue({ + roots: ['deep:build'], + tasks: { + 'myproj:build': { + id: 'myproj:build', + target: { project: 'myproj', target: 'build' }, + overrides: {}, + outputs: [], + }, + 'mid:build': { + id: 'mid:build', + target: { project: 'mid', target: 'build' }, + overrides: {}, + outputs: [], + }, + 'deep:build': { + id: 'deep:build', + target: { project: 'deep', target: 'build' }, + overrides: {}, + outputs: [], + }, + }, + dependencies: { + 'myproj:build': ['mid:build'], + 'mid:build': ['deep:build'], + 'deep:build': [], + }, + continuousDependencies: {}, + }); + mockGetStructuredInputs.mockReturnValue({ + selfInputs: [], + depsInputs: [], + depsOutputs: [ + { dependentTasksOutputFiles: '**/*.d.ts', transitive: true }, + ], + projectInputs: [], + depsFilesets: [], + } as ReturnType); + mockGetOutputs.mockImplementation(((t: { + project?: string; + target?: { project?: string }; + }) => + (t.project ?? t.target?.project) === 'deep' + ? ['libs/deep/dist'] + : []) as typeof getOutputsForTargetAndConfiguration); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + + // The matching output lives on the transitive dep `deep:build`. + expect( + resolver.isInput('myproj:build', 'libs/deep/dist/index.d.ts') + ).toBe(true); + }); + + it('does NOT walk transitive deps when transitive flag is false (default)', async () => { + const graph = buildGraph(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs([]), + }); + mockCreateTaskGraph.mockReturnValue({ + roots: ['deep:build'], + tasks: { + 'myproj:build': { + id: 'myproj:build', + target: { project: 'myproj', target: 'build' }, + overrides: {}, + outputs: [], + }, + 'mid:build': { + id: 'mid:build', + target: { project: 'mid', target: 'build' }, + overrides: {}, + outputs: [], + }, + 'deep:build': { + id: 'deep:build', + target: { project: 'deep', target: 'build' }, + overrides: {}, + outputs: [], + }, + }, + dependencies: { + 'myproj:build': ['mid:build'], + 'mid:build': ['deep:build'], + 'deep:build': [], + }, + continuousDependencies: {}, + }); + mockGetStructuredInputs.mockReturnValue({ + selfInputs: [], + depsInputs: [], + depsOutputs: [ + { dependentTasksOutputFiles: '**/*.d.ts', transitive: false }, + ], + projectInputs: [], + depsFilesets: [], + } as ReturnType); + // Only `deep` declares a matching output dir; `mid` declares nothing. + mockGetOutputs.mockImplementation(((t: { + project?: string; + target?: { project?: string }; + }) => + (t.project ?? t.target?.project) === 'deep' + ? ['libs/deep/dist'] + : []) as typeof getOutputsForTargetAndConfiguration); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + + // With transitive=false, only direct dep `mid:build` is consulted, and + // it has no matching outputs. + expect( + resolver.isInput('myproj:build', 'libs/deep/dist/index.d.ts') + ).toBe(false); + }); + }); + + describe('matchesDependentTaskOutputs', () => { + it('exposes the same dep-outputs check as a standalone method', async () => { + const graph = buildGraphWithDeps(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs([]), + }); + mockCreateTaskGraph.mockReturnValue({ + roots: ['dep:build'], + tasks: { + 'myproj:build': { + id: 'myproj:build', + target: { project: 'myproj', target: 'build' }, + overrides: {}, + outputs: [], + }, + 'dep:build': { + id: 'dep:build', + target: { project: 'dep', target: 'build' }, + overrides: {}, + outputs: [], + }, + }, + dependencies: { + 'myproj:build': ['dep:build'], + 'dep:build': [], + }, + continuousDependencies: {}, + }); + mockGetStructuredInputs.mockReturnValue({ + selfInputs: [], + depsInputs: [], + depsOutputs: [ + { dependentTasksOutputFiles: '**/*.d.ts', transitive: false }, + ], + projectInputs: [], + depsFilesets: [], + } as ReturnType); + mockGetOutputs.mockImplementation(((t: { + project?: string; + target?: { project?: string }; + }) => + (t.project ?? t.target?.project) === 'dep' + ? ['libs/dep/dist'] + : []) as typeof getOutputsForTargetAndConfiguration); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + expect( + resolver.matchesDependentTaskOutputs( + 'myproj:build', + 'libs/dep/dist/index.d.ts' + ) + ).toBe(true); + expect( + resolver.matchesDependentTaskOutputs( + 'myproj:build', + 'libs/dep/dist/index.js' + ) + ).toBe(false); + }); + }); + + describe('isOutput', () => { + it('returns true for an exact match against an output pattern', async () => { + const graph = buildGraph(); + mockGetOutputs.mockReturnValue(['dist/libs/myproj/index.js']); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + expect( + resolver.isOutput('myproj:build', 'dist/libs/myproj/index.js') + ).toBe(true); + }); + + it('returns true when path is nested inside an output directory', async () => { + const graph = buildGraph(); + mockGetOutputs.mockReturnValue(['dist/libs/myproj']); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + expect( + resolver.isOutput('myproj:build', 'dist/libs/myproj/deep/file.js') + ).toBe(true); + }); + + it('returns true for a glob pattern match (dist/**)', async () => { + const graph = buildGraph(); + mockGetOutputs.mockReturnValue(['dist/**']); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + expect(resolver.isOutput('myproj:build', 'dist/main.js')).toBe(true); + expect(resolver.isOutput('myproj:build', 'dist/nested/chunk.js')).toBe( + true + ); + }); + + it('returns false for a path that does NOT match any output pattern', async () => { + const graph = buildGraph(); + mockGetOutputs.mockReturnValue(['dist/**']); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + expect(resolver.isOutput('myproj:build', 'other-dir/file.js')).toBe( + false + ); + }); + }); + + describe('caching across both getInputs and getOutputs', () => { + it('does not call underlying APIs more than once per taskId even with mixed calls', async () => { + const graph = buildGraph(); + mockInspectTaskInputs.mockReturnValue({ + 'myproj:build': makeHashInputs(['libs/myproj/src/index.ts']), + }); + mockGetOutputs.mockReturnValue(['dist/libs/myproj']); + + const resolver = await createTaskFileResolver({ projectGraph: graph }); + + // Interleaved calls for the same taskId + resolver.getInputs('myproj:build'); + resolver.getOutputs('myproj:build'); + resolver.getInputs('myproj:build'); + resolver.getOutputs('myproj:build'); + + expect(mockInspectTaskInputs).toHaveBeenCalledTimes(1); + expect(mockGetOutputs).toHaveBeenCalledTimes(1); + }); + }); + + describe('parseTaskId validation', () => { + it('throws on a taskId that has no colon (no target)', async () => { + const graph = buildGraph(); + const resolver = await createTaskFileResolver({ projectGraph: graph }); + + expect(() => resolver.getInputs('justproject')).toThrow( + /Invalid taskId "justproject"/ + ); + }); + + it('throws on an empty taskId string', async () => { + const graph = buildGraph(); + const resolver = await createTaskFileResolver({ projectGraph: graph }); + + expect(() => resolver.getInputs('')).toThrow(/Invalid taskId ""/); + }); + }); +}); diff --git a/packages/nx/src/hasher/task-file-resolver.ts b/packages/nx/src/hasher/task-file-resolver.ts new file mode 100644 index 0000000000000..dc27814890da0 --- /dev/null +++ b/packages/nx/src/hasher/task-file-resolver.ts @@ -0,0 +1,264 @@ +import { minimatch } from 'minimatch'; +import { type NxJsonConfiguration, readNxJson } from '../config/nx-json'; +import type { ProjectGraph } from '../config/project-graph'; +import type { Task, TaskGraph } from '../config/task-graph'; +import type { HashInputs } from '../native'; +import { createTaskGraph } from '../tasks-runner/create-task-graph'; +import { getOutputsForTargetAndConfiguration } from '../tasks-runner/utils'; +import { splitByColons } from '../utils/split-target'; +import { workspaceRoot as defaultWorkspaceRoot } from '../utils/workspace-root'; +import { HashPlanInspector } from './hash-plan-inspector'; +import { type ExpandedDepsOutput, getInputs } from './task-hasher'; + +export interface TaskFileResolver { + /** Full hash plan entry (files + runtime + environment + depOutputs + external). */ + getRawInputs(taskId: string): HashInputs | null; + getInputs(taskId: string): string[]; + getOutputs(taskId: string): string[]; + isInput(taskId: string, path: string): boolean; + isOutput(taskId: string, path: string): boolean; + /** + * True iff `path` matches a `dependentTasksOutputFiles` glob declared on the + * task AND lies inside the declared outputs of an upstream task in the + * task graph. Works without the upstream tasks having actually run, so + * static path validation (e.g. sandbox-report verification) is supported. + */ + matchesDependentTaskOutputs(taskId: string, path: string): boolean; +} + +export async function createTaskFileResolver(options: { + projectGraph: ProjectGraph; + nxJson?: NxJsonConfiguration; + workspaceRoot?: string; +}): Promise { + const workspaceRoot = options.workspaceRoot ?? defaultWorkspaceRoot; + let resolvedNxJson: NxJsonConfiguration | undefined = options.nxJson; + function getNxJson(): NxJsonConfiguration { + return (resolvedNxJson ??= readNxJson(workspaceRoot)); + } + const inspector = new HashPlanInspector( + options.projectGraph, + workspaceRoot, + options.nxJson + ); + await inspector.init(); + + // Cache the full HashInputs (null = task not found). A single cache entry + // serves both getRawInputs() and getInputs() so the inspector is never + // called more than once per taskId. + const hashInputsCache = new Map(); + const outputsCache = new Map(); + const taskGraphCache = new Map(); + const depsOutputsCache = new Map(); + + function parseTaskId(taskId: string): { + project: string; + target: string; + configuration?: string; + } { + const [project, target, configuration] = splitByColons(taskId); + if (!project || !target) { + throw new Error( + `Invalid taskId "${taskId}" — expected "project:target[:configuration]"` + ); + } + return { project, target, configuration }; + } + + function getRawInputs(taskId: string): HashInputs | null { + if (hashInputsCache.has(taskId)) { + return hashInputsCache.get(taskId) ?? null; + } + + const { project, target, configuration } = parseTaskId(taskId); + + let planResult: Record = {}; + try { + planResult = inspector.inspectTaskInputs({ + project, + target, + configuration, + }); + } catch { + // Project / target not found in graph — treat as no inputs. + hashInputsCache.set(taskId, null); + return null; + } + + // The result key is usually the same as taskId but may include a + // defaultConfiguration suffix when none was explicitly given. + let inputs: HashInputs | undefined = planResult[taskId]; + if (!inputs) { + const prefix = `${project}:${target}`; + for (const [key, val] of Object.entries(planResult)) { + if (key === prefix || key.startsWith(prefix + ':')) { + inputs = val; + break; + } + } + } + + const result = inputs ?? null; + hashInputsCache.set(taskId, result); + return result; + } + + function getInputsImpl(taskId: string): string[] { + return getRawInputs(taskId)?.files ?? []; + } + + function getOutputs(taskId: string): string[] { + const cached = outputsCache.get(taskId); + if (cached !== undefined) return cached; + + const { project, target, configuration } = parseTaskId(taskId); + const node = options.projectGraph.nodes[project]; + const outputs = node?.data?.targets?.[target] + ? getOutputsForTargetAndConfiguration( + { project, target, configuration }, + {}, + node + ) + : []; + + outputsCache.set(taskId, outputs); + return outputs; + } + + function getTaskGraphFor(taskId: string): TaskGraph | null { + if (taskGraphCache.has(taskId)) return taskGraphCache.get(taskId) ?? null; + const { project, target, configuration } = parseTaskId(taskId); + if (!options.projectGraph.nodes[project]) { + taskGraphCache.set(taskId, null); + return null; + } + let tg: TaskGraph | null = null; + try { + tg = createTaskGraph( + options.projectGraph, + {}, + [project], + [target], + configuration, + {}, + false + ); + } catch { + tg = null; + } + taskGraphCache.set(taskId, tg); + return tg; + } + + function findCanonicalTaskId(taskId: string, tg: TaskGraph): string | null { + if (tg.tasks[taskId]) return taskId; + const { project, target } = parseTaskId(taskId); + const prefix = `${project}:${target}`; + for (const id of Object.keys(tg.tasks)) { + if (id === prefix || id.startsWith(prefix + ':')) return id; + } + return null; + } + + function getDepsOutputs(taskId: string): ExpandedDepsOutput[] { + if (depsOutputsCache.has(taskId)) return depsOutputsCache.get(taskId)!; + + const tg = getTaskGraphFor(taskId); + if (!tg) { + depsOutputsCache.set(taskId, []); + return []; + } + const canonical = findCanonicalTaskId(taskId, tg); + if (!canonical) { + depsOutputsCache.set(taskId, []); + return []; + } + const task = tg.tasks[canonical] as Task; + let result: ExpandedDepsOutput[] = []; + try { + result = + getInputs(task, options.projectGraph, getNxJson()).depsOutputs ?? []; + } catch { + result = []; + } + depsOutputsCache.set(taskId, result); + return result; + } + + function getUpstreamTaskIds(taskId: string, transitive: boolean): string[] { + const tg = getTaskGraphFor(taskId); + if (!tg) return []; + const canonical = findCanonicalTaskId(taskId, tg); + if (!canonical) return []; + const direct = tg.dependencies[canonical] ?? []; + if (!transitive) return [...direct]; + const visited = new Set(); + const queue = [...direct]; + while (queue.length) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + queue.push(...(tg.dependencies[id] ?? [])); + } + return [...visited]; + } + + function pathMatchesOutputPattern( + normalizedPath: string, + pattern: string + ): boolean { + const np = pattern.replace(/\\/g, '/'); + return ( + normalizedPath === np || + normalizedPath.startsWith(np + '/') || + minimatch(normalizedPath, np, { dot: true }) + ); + } + + function isOutputImpl(taskId: string, path: string): boolean { + const normalized = path.replace(/\\/g, '/'); + return getOutputs(taskId).some((p) => + pathMatchesOutputPattern(normalized, p) + ); + } + + function matchesDependentTaskOutputs(taskId: string, path: string): boolean { + const normalized = path.replace(/\\/g, '/'); + const depsOutputs = getDepsOutputs(taskId); + if (depsOutputs.length === 0) return false; + for (const { dependentTasksOutputFiles, transitive } of depsOutputs) { + if (!minimatch(normalized, dependentTasksOutputFiles, { dot: true })) { + continue; + } + const upstreamIds = getUpstreamTaskIds(taskId, !!transitive); + for (const upstreamId of upstreamIds) { + if (isOutputImpl(upstreamId, normalized)) return true; + } + } + return false; + } + + return { + getRawInputs, + getInputs: getInputsImpl, + getOutputs, + matchesDependentTaskOutputs, + isInput(taskId: string, path: string): boolean { + const normalized = path.replace(/\\/g, '/'); + const raw = getRawInputs(taskId); + if (raw) { + if (raw.files.includes(path) || raw.files.includes(normalized)) { + return true; + } + if ( + raw.depOutputs.includes(path) || + raw.depOutputs.includes(normalized) + ) { + return true; + } + } + return matchesDependentTaskOutputs(taskId, normalized); + }, + isOutput: isOutputImpl, + }; +}