diff --git a/packages/workspace/src/tasks-runner/hasher.spec.ts b/packages/workspace/src/tasks-runner/hasher.spec.ts index 352e3c4de9..318304010f 100644 --- a/packages/workspace/src/tasks-runner/hasher.spec.ts +++ b/packages/workspace/src/tasks-runner/hasher.spec.ts @@ -6,7 +6,14 @@ jest.mock('hasha'); jest.mock('fs'); describe('Hasher', () => { - let hashes = { 'yarn.lock': 'yarn.lock.hash' }; + let hashes = { + 'yarn.lock': 'yarn.lock.hash', + 'nx.json': 'nx.json.hash', + 'package-lock.json': 'package-lock.json.hash', + 'package.json': 'package.json.hash', + 'tsconfig.json': 'tsconfig.json.hash', + 'workspace.json': 'workspace.json.hash', + }; beforeEach(() => { hasha.mockImplementation((values) => values.join('|')); hasha.fromFile.mockImplementation((path) => Promise.resolve(hashes[path])); @@ -43,13 +50,30 @@ describe('Hasher', () => { overrides: { prop: 'prop-value' }, }); - expect(hash).toContain('yarn.lock.hash'); //implicits - expect(hash).toContain('file.hash'); //project files - expect(hash).toContain('prop-value'); //overrides - expect(hash).toContain('proj'); //project - expect(hash).toContain('build'); //target - expect(hash).toContain('runtime123'); //target - expect(hash).toContain('runtime456'); //target + expect(hash.value).toContain('yarn.lock.hash'); //implicits + expect(hash.value).toContain('file.hash'); //project files + expect(hash.value).toContain('prop-value'); //overrides + expect(hash.value).toContain('proj'); //project + expect(hash.value).toContain('build'); //target + expect(hash.value).toContain('runtime123'); //target + expect(hash.value).toContain('runtime456'); //target + + expect(hash.details.command).toEqual('proj|build||{"prop":"prop-value"}'); + expect(hash.details.sources).toEqual({ + proj: 'file.hash', + }); + expect(hash.details.implicitDeps).toEqual({ + 'yarn.lock': 'yarn.lock.hash', + 'nx.json': 'nx.json.hash', + 'package-lock.json': 'package-lock.json.hash', + 'package.json': 'package.json.hash', + 'tsconfig.json': 'tsconfig.json.hash', + 'workspace.json': 'workspace.json.hash', + }); + expect(hash.details.runtime).toEqual({ + 'echo runtime123': 'runtime123', + 'echo runtime456': 'runtime456', + }); done(); }); @@ -82,6 +106,46 @@ describe('Hasher', () => { } }); + it('should hash projects with dependencies', async (done) => { + hashes['/filea'] = 'a.hash'; + hashes['/fileb'] = 'b.hash'; + const hasher = new Hasher( + { + nodes: { + parent: { + name: 'parent', + type: 'lib', + data: { files: [{ file: '/filea', ext: '.ts', mtime: 1 }] }, + }, + child: { + name: 'child', + type: 'lib', + data: { files: [{ file: '/fileb', ext: '.ts', mtime: 1 }] }, + }, + }, + dependencies: { + parent: [{ source: 'parent', target: 'child', type: 'static' }], + }, + }, + {} as any, + {} + ); + + const hasha = await hasher.hash({ + target: { project: 'parent', target: 'build' }, + id: 'parent-build', + overrides: { prop: 'prop-value' }, + }); + + // note that the parent hash is based on parent source files only! + expect(hasha.details.sources).toEqual({ + parent: 'a.hash', + child: 'b.hash', + }); + + done(); + }); + it('should hash when circular dependencies', async (done) => { hashes['/filea'] = 'a.hash'; hashes['/fileb'] = 'b.hash'; @@ -114,12 +178,13 @@ describe('Hasher', () => { overrides: { prop: 'prop-value' }, }); - expect(hasha).toContain('yarn.lock.hash'); //implicits - expect(hasha).toContain('a.hash'); //project files - expect(hasha).toContain('b.hash'); //project files - expect(hasha).toContain('prop-value'); //overrides - expect(hasha).toContain('proj'); //project - expect(hasha).toContain('build'); //target + expect(hasha.value).toContain('yarn.lock.hash'); //implicits + expect(hasha.value).toContain('a.hash'); //project files + expect(hasha.value).toContain('b.hash'); //project files + expect(hasha.value).toContain('prop-value'); //overrides + expect(hasha.value).toContain('proj'); //project + expect(hasha.value).toContain('build'); //target + expect(hasha.details.sources).toEqual({ proja: 'a.hash', projb: 'b.hash' }); const hashb = await hasher.hash({ target: { project: 'projb', target: 'build' }, @@ -127,12 +192,13 @@ describe('Hasher', () => { overrides: { prop: 'prop-value' }, }); - expect(hashb).toContain('yarn.lock.hash'); //implicits - expect(hashb).toContain('a.hash'); //project files - expect(hashb).toContain('b.hash'); //project files - expect(hashb).toContain('prop-value'); //overrides - expect(hashb).toContain('proj'); //project - expect(hashb).toContain('build'); //target + expect(hashb.value).toContain('yarn.lock.hash'); //implicits + expect(hashb.value).toContain('a.hash'); //project files + expect(hashb.value).toContain('b.hash'); //project files + expect(hashb.value).toContain('prop-value'); //overrides + expect(hashb.value).toContain('proj'); //project + expect(hashb.value).toContain('build'); //target + expect(hashb.details.sources).toEqual({ proja: 'a.hash', projb: 'b.hash' }); done(); }); @@ -158,11 +224,13 @@ describe('Hasher', () => { {} ); - const hash = await hasher.hash({ - target: { project: 'proja', target: 'build' }, - id: 'proja-build', - overrides: { prop: 'prop-value' }, - }); + const hash = ( + await hasher.hash({ + target: { project: 'proja', target: 'build' }, + id: 'proja-build', + overrides: { prop: 'prop-value' }, + }) + ).value; expect(hash).toContain('yarn.lock.hash'); //implicits expect(hash).toContain('5000001'); //project files diff --git a/packages/workspace/src/tasks-runner/hasher.ts b/packages/workspace/src/tasks-runner/hasher.ts index 623d6c9327..d8d8a71bd0 100644 --- a/packages/workspace/src/tasks-runner/hasher.ts +++ b/packages/workspace/src/tasks-runner/hasher.ts @@ -7,10 +7,35 @@ import { execSync } from 'child_process'; const hasha = require('hasha'); +export interface Hash { + value: string; + details: { + command: string; + sources: { [projectName: string]: string }; + implicitDeps: { [key: string]: string }; + runtime: { [input: string]: string }; + }; +} + +interface ProjectHashResult { + value: string; + sources: { [projectName: string]: string }; +} + +interface ImplicitHashResult { + value: string; + sources: { [fileName: string]: string }; +} + +interface RuntimeHashResult { + value: string; + runtime: { [input: string]: string }; +} + export class Hasher { static version = '1.0'; - implicitDependencies: Promise; - runtimeInputs: Promise; + implicitDependencies: Promise; + runtimeInputs: Promise; fileHashes = new FileHashes(); projectHashes = new ProjectHashes(this.projectGraph, this.fileHashes); @@ -20,13 +45,16 @@ export class Hasher { private readonly options: any ) {} - async hash(task: Task): Promise { - const args = [ - task.target.project || '', - task.target.target || '', - task.target.configuration || '', - JSON.stringify(task.overrides), - ]; + async hash(task: Task): Promise { + const command = hasha( + [ + task.target.project || '', + task.target.target || '', + task.target.configuration || '', + JSON.stringify(task.overrides), + ], + { algorithm: 'sha256' } + ); const values = await Promise.all([ this.projectHashes.hashProject(task.target.project, [ @@ -36,10 +64,25 @@ export class Hasher { this.runtimeInputsHash(), ]); - return hasha([Hasher.version, ...args, ...values], { algorithm: 'sha256' }); + const value = hasha( + [Hasher.version, command, ...values.map((v) => v.value)], + { + algorithm: 'sha256', + } + ); + + return { + value, + details: { + command, + sources: values[0].sources, + implicitDeps: values[1].sources, + runtime: values[2].runtime, + }, + }; } - private async runtimeInputsHash() { + private async runtimeInputsHash(): Promise { if (this.runtimeInputs) return this.runtimeInputs; const inputs = @@ -48,34 +91,65 @@ export class Hasher { : []; if (inputs.length > 0) { try { - const values = await Promise.all( - inputs.map(async (i) => execSync(i).toString().trim()) + const values = (await Promise.all( + inputs.map(async (input) => { + const value = execSync(input).toString().trim(); + return { input, value }; + }) + )) as any; + + const value = await hasha( + values.map((v) => v.value), + { + algorithm: 'sha256', + } ); - this.runtimeInputs = hasha(values, { algorithm: 'sha256' }); + const runtime = values.reduce( + (m, c) => ((m[c.input] = c.value), m), + {} + ); + return { value, runtime }; } catch (e) { throw new Error( `Nx failed to execute runtimeCacheInputs defined in nx.json failed:\n${e.message}` ); } } else { - this.runtimeInputs = Promise.resolve(''); + this.runtimeInputs = Promise.resolve({ value: '', runtime: {} }); } return this.runtimeInputs; } - private async implicitDepsHash() { + private async implicitDepsHash(): Promise { if (this.implicitDependencies) return this.implicitDependencies; - const values = await Promise.all([ - ...Object.keys(this.nxJson.implicitDependencies || {}).map((r) => - this.fileHashes.hashFile(r) - ), - ...rootWorkspaceFileNames().map((r) => this.fileHashes.hashFile(r)), - this.fileHashes.hashFile('package-lock.json'), - this.fileHashes.hashFile('yarn.lock'), - ]); - this.implicitDependencies = hasha(values, { algorithm: 'sha256' }); + const fileNames = [ + ...Object.keys(this.nxJson.implicitDependencies || {}), + ...rootWorkspaceFileNames(), + 'package-lock.json', + 'yarn.lock', + ]; + + this.implicitDependencies = Promise.resolve().then(async () => { + const fileHashes = await Promise.all( + fileNames.map(async (file) => { + const hash = await this.fileHashes.hashFile(file); + return { file, hash }; + }) + ); + + const combinedHash = await hasha( + fileHashes.map((v) => v.hash), + { + algorithm: 'sha256', + } + ); + return { + value: combinedHash, + sources: fileHashes.reduce((m, c) => ((m[c.file] = c.hash), m), {}), + }; + }); return this.implicitDependencies; } } @@ -88,20 +162,36 @@ export class ProjectHashes { private readonly fileHashes: FileHashes ) {} - async hashProject(projectName: string, visited: string[]) { + async hashProject( + projectName: string, + visited: string[] + ): Promise { return Promise.resolve().then(async () => { - const deps = (this.projectGraph.dependencies[projectName] || []).map( - (t) => { - if (visited.indexOf(t.target) > -1) { - return ''; - } else { - visited.push(t.target); - return this.hashProject(t.target, visited); - } - } + const deps = this.projectGraph.dependencies[projectName] || []; + const depHashes = ( + await Promise.all( + deps.map(async (d) => { + if (visited.indexOf(d.target) > -1) { + return null; + } else { + visited.push(d.target); + return await this.hashProject(d.target, visited); + } + }) + ) + ).filter((r) => !!r); + const projectHash = await this.hashProjectNodeSource(projectName); + const sources = depHashes.reduce( + (m, c) => { + return { ...m, ...c.sources }; + }, + { [projectName]: projectHash } ); - const sources = this.hashProjectNodeSource(projectName); - return hasha(await Promise.all([...deps, sources])); + const value = await hasha([ + ...depHashes.map((d) => d.value), + projectHash, + ]); + return { value, sources }; }); } diff --git a/packages/workspace/src/tasks-runner/run-command.ts b/packages/workspace/src/tasks-runner/run-command.ts index 5f162dc7fd..bed1ed3e18 100644 --- a/packages/workspace/src/tasks-runner/run-command.ts +++ b/packages/workspace/src/tasks-runner/run-command.ts @@ -44,7 +44,9 @@ export async function runCommand( const hasher = new Hasher(projectGraph, nxJson, tasksOptions); await Promise.all( tasks.map(async (t) => { - t.hash = await hasher.hash(t); + const hash = await hasher.hash(t); + t.hash = hash.value; + t.hashDetails = hash.details; }) ); diff --git a/packages/workspace/src/tasks-runner/tasks-runner.ts b/packages/workspace/src/tasks-runner/tasks-runner.ts index 70e63993ac..70838f4be2 100644 --- a/packages/workspace/src/tasks-runner/tasks-runner.ts +++ b/packages/workspace/src/tasks-runner/tasks-runner.ts @@ -9,6 +9,12 @@ export interface Task { target: Target; overrides: Object; hash?: string; + hashDetails?: { + command: string; + sources: { [projectName: string]: string }; + implicitDeps: { [key: string]: string }; + runtime: { [input: string]: string }; + }; } export enum AffectedEventType {